Self Hosting Ghost

6 min read min read

I thought I'd detail my process of self hosting ghost, in case it'd help anyone. It's required quite a bit of tinkering to get fully setup (but half of that is probably self-inflicted).

I thought I'd detail my process of self hosting ghost, in case it'd help anyone. It's required quite a bit of tinkering to get fully setup (but half of that is probably self-inflicted).

Where I moved from

I had been using 11ty for the past several months with a pretty vanilla design and pretty specific setup. If you aren't familiar with 11ty, it's a "Static Site Generator" or SSG for short. That means any time you update it (typically through adding a markdown file to the server space - at least for me), 11ty has a script that you run which converts everything to HTML so that it can be served as a site somewhere.

Dynamic content (like blog posts) are called collections and I used 3 of them: a collection for blog posts, a collection for links I wanted to share and a collection of things I'd written elsewhere on the web (like toots). The first and last in particular allowed me to setup a nice timeline view of my web activity.

Why Ghost?

It's probably weird to say but I have a soft spot for Ghost - I think it's really well done as a substack/wordpress replacement and has a really pleasant user experience on the admin side of things. 11ty's admin is the command line terminal (unless you setup some type of alternate CMS); Ghost, having that nice admin side, then makes it easier to have a system to write, edit and publish that doesn't require as many moves between systems.

It also allows me to easily add a feature my wife has been bugging me for: email newsletter! She wants to have my posts emailed to her and not be bothered to come to the site (which is fine I guess) and Ghost makes that really easy. In the past I've used pico.sh's rss-to-email feature to achieve this but its not a frictionless system for me or the end user.

đź’ˇ
Sidenote: Pico.sh is a great service though and worth checking out! They've got lots of cool tools kind of like omglol.

Making the move

To make the move I decided a few things on the frontend:

  • I wanted to keep my general aesthetic and function AND
  • I wanted to self-host AND
  • I didn't want to move until I had tested my suspicion that I'd love Ghost

The VPS that my 11ty site was on has plenty of resources for a Ghost container so I decided to spin up a (slightly modified) version of the Ghost Docker developer preview. The main change I made was commenting out the Caddy container as I run Caddy locally serving a number of different sites. This made things tricky later on though (but I'll get into that).

Because of the third bullet above, I started out on a development domain. Ghost booted fine and I was able to make an admin account and get going, so to speak.

Setting up the theme

I'll be honest here and say "Claude helped". I know some might stop reading at this point - sorry! - but it provided a clearer understanding of the framework needed to transition the 11ty design to a Ghost theme. After a few evenings of head deep in html, css, njk and some js I had something I was happy with. Chiefly I got:

  • a custom Timeline page that lists my longform posts here, the short form posts I make elsewhere (like at Crucial Tracks) and my postroll posts against the backdrop of time. It's an easy archive view of my presence on the web.
  • a redesigned homepage with my longform posts and postroll posts interwoven.
  • javascript that replaces the postroll posts link with the link to the original article.
  • a deeper understanding of webfonts and script styles.
  • a deep dive into how routing works in Ghost.

Importing content

I had a lot of content in markdown files to import which was a lot trickier than I had hoped. Ghost has an API that opens up the possibility of scripting something and that's the first thing I tried but struggled to get it to work properly. I got post titles and dates and tags but no content and excerpts. Over and over and over again. And again. I tried too many times.

I finally went with a script that converted the markdown files into a big JSON file that Ghost could import natively through the admin interface - that worked great. For importing, I'd go with this method.

👨‍💻
I'm happy to share code snippets if it'd be helpful - reach out!

Making the switch

Making the domain switch was a bit tricky as two things needed to happen:

  • Figuring out if I could change the domain in Ghost or if I needed to setup a new instance (I should have done the latter)
  • Figuring out Caddy specifics since I wasn't using the built in Caddy container

Because more sites are hosted on this VPS than Ghost (go say "hi" to my wife at Jones&Pages!), the built in Caddy container would have just been broken because those ports were already in use so I needed to make sure the necessary code (scripted in the compose file and accompanying snippets) made it into my local Caddyfile. This is what it ended up looking like:

krrd.ing {
        # Log all requests
        log {
                output stdout
                format console
                level INFO
        }

        # Traffic Analytics service
        # Proxy analytics requests with any prefix (e.g. /.ghost/analytics/ or /blog/.ghost/analytics/)
        # @analytics_paths path_regexp analytics_match ^(.*)/\.ghost/analytics(.*)$
        # handle @analytics_paths {
        #       rewrite * {re.analytics_match.2}
        #       reverse_proxy 0.0.0.0:3009
        # }

        # ActivityPub Service
        # ActivityPub
        # Proxy activitypub requests /.ghost/activitypub/
        handle /.ghost/activitypub/* {
                reverse_proxy https://ap.ghost.org
        }

        handle /.well-known/webfinger {
                reverse_proxy https://ap.ghost.org
        }

        handle /.well-known/nodeinfo {
                reverse_proxy https://ap.ghost.org
        }

        # Default proxy everything else to Ghost
        handle {
                reverse_proxy 0.0.0.0:<Port>
        }

        # Optional: Enable gzip compression
        encode gzip

        # Optional: Add security headers
        header {
                # Enable HSTS
                Strict-Transport-Security max-age=31536000;
                # Prevent embedding in frames
                # X-Frame-Options DENY
                # Enable XSS protection
                X-XSS-Protection "1; mode=block"
                # Prevent MIME sniffing
                X-Content-Type-Options nosniff
                # Referrer policy
                Referrer-Policy strict-origin-when-cross-origin
        }
        
        tls {
                dns cloudflare <token>
                resolvers 1.1.1.1
        }
}

A couple of notes:

  • My X-Frame-Options DENY is commented out because of some backend errors.
  • I use DNS challenges to negotiate the LE certificate but you don't have to.
  • I've got the analytics stuff commented out because I'm not using Ghost's built in analytics.
  • This is an important address if you want the ActivityPub integration: https://ap.ghost.org. For low traffic sites, Ghost.org allows you to use the Ghost(Pro) AP server which saves on local server resource needs. If memory serves me, if you have under 2000 followers you can use it.

Social web integration

Social web is what Ghost is calling their ActivityPub integration. I had a lot of problems getting it setup but it was my own fault. I'll mention the problem here in case it helps anyone.

Basically, the “Social Web” Network tab constantly just gave a “Site not configured correctly” errors. There weren’t any overt errors in the docker logs for the container other than complaints about no webhook_secret being available. In the course of trying to figure it out though I started noticing that the logs seemed “off”. Looking more closely they were 10 minutes off. Digging deeper I learned about something called "HyperV time drift" which is where a VPS's time can drift. 

The solution was pretty straightforward. I installed NTP on the VM and got it setup and restarted the docker container and viola! the network tab worked. So that's something to keep an eye (that I'd never heard of before).

Automating the boring stuff

The final major step was automating the boring stuff. By that I mean creating postroll posts automatically when I saved worthwhile articles into Linkding and creating posts automatically from the RSS feeds from services like Mastodon and Crucial Tracks. I didn't want to do that manually.

Thankfully half the work was done as this part was already automated for 11ty. I just had to get the Ghost Admin API working. After a bit of trial and error, and then some more, I got it sorted and a cron job setup. Hourly, my VPS checks Linkding and various RSS feeds and if it finds something new it uses the Ghost API to make a new post and then it updates a local data file that it can scan against to make sure their aren't repeat posts.

Odds and ends

I'm still tweaking things, like the format my automations use and design things. I'm really happy with where it's at though!

I didn't mention it above but I also got the newsletter functionality working with mailgun so you can even sign up for emails if you want! I don't plan on moving it anytime soon (and knock on wood those aren't famous last words)...

Thanks for reading!

I'd love to hear from you if you have a comment, suggestion, clarification or anything! Feel free to email me or respond on Mastodon below. If you really loved it, you can buy me a coffee!