This Blog in Eleventy + Ghost

This Blog in Eleventy + Ghost

This past weekend my daughter, Mackenzie, made the decision to move her portfolio page into a configuration that features Eleventy (11ty) on the frontend with content managed in Ghost on the backend.  I like that combination very much so to help support her move, and learn a little something along the way, we've setup a new Ghost server in a DigitalOcean droplet, namely ghostonubuntu2204-s-1vcpu-1gb-intel-nyc1-01 , and I've decided to make the same move myself.  

This post is intended to track my blog's journey from a git workflow (for both structure and content) in Hugo, to Eleventy + Ghost, and it begins with the guidance provided in  I already have a working Ghost server as mentioned above, the one that we setup for Mackenzie's portfolio, and we've already followed Install and host Multiple Ghost blog Servers on One Server to create the Ghost backend for this blog on the same DO droplet that host's Mackenzie's portfolio content.  The admin interface for my blog instance of Ghost is shown below.  

Ghost Admin
The backend: My admin interface.

Creating the 11ty Frontend

Working locally on my Mac Mini this is the input I captured...

cd ~/GitHub
git clone blog-eleventy-ghost
cd blog-eleventy-ghost
yarn start

Note that complete input and output from all commands is available in

That worked flawlessly and it's quick too!  

Saving This Work in GitHub

Time to save what's been done here before our progress is lost, and I didn't fork the TryGhost/eleventy-starter-ghost project because I don't intend to try and improve it, so my project needs a new GitHub home.  My typical process for doing that looks something like this...

rm -fr .git
git init
git add .
git commit -m "First commit"
git branch -M main
git remote add origin
git push -u origin main

Replacing the Ghost Backend

By default the project I'm using connects its 11ty frontend to a Ghost backend at  Fortunately, my project's file, an ammended copy from the original TryGhost project, also provides the simple guidance required to change that, like so...

First, I visited to create new API keys, and saved them in my password vault.  Next, I needed to edit the project's .env file to read like this:

# GHOST_CONTENT_API_KEY=5a562eebab8528c44e856a3e0a

Done.  When the instance of Ghost was created I took steps to "make it private", so it should function as a backend only.   So, running yarn start with the new .env configuration should give me a stripped-down localhost:8080 edition of the blog pulling content from the intended Ghost backend, like so:

yarn start

Yup, that works!  I get something that looks like this now:

My new blog connected to

Even better, when I save and Publish my work on this post, effectively removing its "Draft" status in, I should see an instantaneous update in my https://localhost:8080 instance of the blog.  Wish me luck...  

Beautimous!  Well, almost.  The change to the site was not "instantaneous", but after a quick ctrl-c and a new yarn start we have this:

New blog updated with an initial copy of this post.

Migrating Content

Now that we have a working local site, it's time to copy some content (posts and microposts) that I wish to keep from my old blog at into For now I'm doing that "manually" by opening a new post and copying content from old .md files into them one-at-a-time. Using that tedious process I managed to copy 8 posts forward into this blog.

Along the way I also did some quick investigation of post2ghost, a utility aimed at helping populate Ghost posts from .md files. Great idea! Unfortunately, it looks like that work relies on an ancient version of the Ghost API, and I don't think it will work at all with the latest due to extensive changes in the API.

Still, I very much like the idea behind post2ghost and I also like the name. Maybe I'll reach out and see if I can borrow the name and leverage the new Ghost API along with my Hugo Front Matter Tools project to create a Python script that will read .md files with front matter and turn them into API-compatible JSON for bulk creation of new Ghost posts. More on that effort later...

Next Step - Deploy and Assign an Address

Currently this new blog is only available locally as https://localhost:8080 because it does not yet deploy to any internet host. The Eleventy Starter Ghost project that I'm following is equiped to deploy a site to Netlify and use deploy hooks from Ghost to trigger Netlify rebuilds.

Netlify is fine, but I only use them as a last resort, and thus far that's only for the Wieting Theatre where I rely on NetlifyCMS to manage backend content. Ironically, NetlifyCMS was just rebranded as Decap CMS. See for details.

So, I'm going to try and reproduce the triggers, hooks and actions intended for Netlify with my own GitHub Action (plus whatever it takes) to deploy this blog as That URL currently delivers a Hugo static site hosted on DigitalOcean (DO). Since my Ghost instance is also on DO I think I'll take a crack at deploying this blog to DO App Platform as a Starter App. I hope that the guidance provided in Step 11 — Deploying to DigitalOcean with App Platform proves helpful.

Step 11 — Deploying to DigitalOcean with App Platform - My Specifics

The aforementioned guidance did indeed prove helpful, although it is a little out-dated compared to my acutal experience, which is detailed below in up-to-date (as of March 2023) words and pictures, of course.

First I visited my DigitalOcean account and clicked the Create button as directed.

Image file is 0001.png
Figure 1. Replacing the `blogs-summitt-dweller` app in DigitalOcean. Click `Create`.

Next, I selected Apps as directed in order to initiate the creation of a new App.

Image file is 0011.png
Figure 2. Select 'Apps'.

This app is to be built from a GitHub repo so I chose GitHub, allowed the system to lookup all the GitHub repositories I've given DigitalOcean permisison to "see".

Image file is 0027.png
Figure 3. Select `GitHub` and then the new site project repo.

I choose the my SummittDweller/blog-eleventy-ghost repo.

Image file is 0045.png
Figure 4. Choose the repo.

Then accepted all the defaults for the main branch, the / root directory (of the project repo), the default for Auto Deploy, and clicked Next to proceed.

Image file is 0065.png
Figure 5. Accept defaults and click `Next`.

Unfortunately, when I initiated the process DigitalOcean detected the repo as a "Web Service", perhaps because of the netlify.toml file? I don't want to create a "Web Service" since those are more complex and expensive than a simple static 11ty site. So, I choose to edit the Resource Type to change that.

Image file is 0861.png
Figure 6. Edit the `Resource Type`...

I subsequently choose a Static Site and clicked to Save the change.

Image file is 0916.png
Figure 7. Choose `Static Site` and click `Save`.

My 11ty project generates static content into the project's /dist directory so I edited the Output Directory setting, set it to dist and clicked to Save that change.

Image file is 0954.png
Figure 8. Edit the `Output Directory`...
Image file is 0970.png
Figure 9. Set to `dist` and click `Save`.

Next I clicked Back to find the "Billing" section, review the new deployment fees (should be $3/month or less), and click Create Resources to begin the first build and deployment.

Image file is 1003.png
Figure 10. Click `Back`...
Image file is 1114.png
Figure 11. Review fees and click `Create Resources`.

It took a few minutes, but once the app is complete you should see a screen like that shown in Figure 12 below. Another unfortunate stumble... I forgot to change my App name up front so it carries a meaningless, random name of coral-app. Let's change that now.

Image file is 1139.png
Figure 12. App was created, but with a meaningless name.

I clicked Edit in the "App Settings / Info" section and changed the name to better represent the project and site that's been created.

Image file is 1258.png
Figure 13. Edit the App Settings Info to change the name.
Image file is 1461.png
Figure 14. Specify a better name and click `Save`.

Note that the unique URL generated for the app did NOT change. I fear we are stuck with that name, but it won't be a concern for very long. Once the deployment is done you should see a Live App link/button. Click it!

Image file is 1494.png
Figure 15. Wait for the app to deploy and click `Live App`.

Eureka! We should have a new site deployed to DigitalOcean.

Image file is 1513.png
Figure 16. Eureka, we have an app!

One more thing to do, give this new site a meaningful URL. Open the Manage screen and our new app, the click the Settings tab.

Image file is 2014.png
Figure 17. Manage the app and click `Settings`.

Click Add Domain and enter the "correct" URL. In this case the app needs to respond at

Image file is 2024.png
Figure 18. Click `Add Domain`.

My registar already points the domain at the DigitalOcean DNS servers, so I choose DigitalOcean DNS as the keeper of the domain's records.

Image file is 2062.png
Figure 19. Enter the new domain name and choose DigitalOcean DNS.

After the domain is added I needed great patience since I could visit the site, but no valid certificates had been generated yet. I was able to check my DigitalOcean Network screen to see that a new CNAME record has been added for the site, and after about 30 minutes I was finally able to open the "secure" site at

Image file is 2165.png
Figure 20. Be patient, the site is not instantly secure.
Image file is 2450.png
Figure 21. Visit `Networking` and observe a new CNAME record.

Customization: Formatting Figures and Matomo

Ok, now that the domain is added I wanted to introduce my first bonafide customizations, namely

  • Better formatting of figures like you see in the previous section and below, and
  • Introduction of my Matomo tracking code.

Using Code Injection

Ghost has a nice feature called Code Injection that allows me to inject snippets of Javascript and CSS into every page's header and footer. Not sure I'm "sold" on the use of Code Injection since it puts "code" into a database, and that's something I don't care to do. However, as a short-term "test" it's a wonderful feature.

To engage it I visited my blog's Ghost admin page and clicked on the gear icon (lower-left red box in Figure 22 below) then selected the Code Injection as shown below. The code injection screen that opens provides both header and footer editing spaces where Javascript and/or CSS can be added.

Figure 22. Opening Ghost Code Injection.

The CSS that I added in the header was:

  p > figure > figcaption {
    font-weight: 400;
    font-style: italic;
    font-size: 16px;
    color: black;
    outline: 0;
    z-index: 300;
    padding: 2px 5px;
    text-align: left;
  p > figure > img {
    border: 1px solid black;

The Javascript code injected into the footer was:

  // Creates Captions from img Title attributes
    function() {
      // Let's make the title a caption
      if ($(this).attr("title")) {
          '<figure class="figure"></figure>'
          '<figcaption class="figure">' +
             $(this).attr("title") +

Both of the above code snippets were inspired by blogger Kevin Chung and his Adding image captions to Ghost post. Kevin's code looked for any ![alt](src) image references in the content and reformatted them into HTML that looked something like this:

<figure class="figure">
  <img src="" alt="Image file is 0011.png" title="Figure 2. Select 'Apps'." loading="lazy"/>
  <figcaption class="figure">Figure 2. Select 'Apps'.</figcaption>

Kevin's original code modified ALL of the images, the <img> tags, in the post if they had alt attributes, and in my case that's ALL images. That's not what I wanted to happen, so I reworked the logic so that only images (<img>) with title attributes would get captured and reformatted. That worked nicely, but my results were inconsistent. The behavior I saw in localhost rendering was different than in my Ghost site, and sometimes differnt than in my Elevent site. Not good.

All of the Markdown for the figures, like those in this section and above, that I wanted to control is generated using my Convert Videos to Frames utility, I chose to take the bull by the horns and just have it generate exactly the <figure> and <figcaption> HTML that I wanted to see. So, the raw Markdown for the figure you see above is simply:

  <img class="figure" alt="Ghost-Code-Injection-01.png" src="" />
  <figcaption class="figure"> 
    Figure 22. Opening Ghost Code Injection.

The images in this instance are housed as BLOBs in Azure Storage and served directly from there.

The simple/raw approach that I ultimately arrived at is consistent and to-the-point, and it doesn't need any Ghost code injection so I removed all of the "injected" code from I also found that I could achieve better, more consistent CSS control by adding custom code to the end of the project's src/_includes/css/styles.css file like so:

/* Summitt Dweller styles
/* ---------------------------------------------------------- */

figure img.figure.lazyloaded {
  border: 1px solid black;

figure {
  text-align: center;

.post-full-content figcaption {
  margin: 0.2em 3em 0;
  font-size: 55%;
  line-height: 1.6em;
  text-align: center;
  font-weight: 400;

code {
  font-family: monospace,monospace;
  font-size: .75em;
  background: #eceebd;
  padding: 2px 5px;
  border-radius: 5px;

pre > code {
  background: none;
  padding: 0;

The intent is to wrap the images with a class="figure" attribute in a nice little black border, and more!


While Ghost code injection didn't behave well for reformatting figures, it does work nicely, and consistently, for Matomo tracking! However, I'd still prefer to put as much as possible into code, not into a database, so I grabbed a copy of my Matomo code that's specifically for this blog and applied it like so in the project's src/_includes/layouts/default.njk file:

<!-- Matomo -->
  var _paq = window._paq = window._paq || [];
  /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
  (function() {
    var u="";
    _paq.push(['setTrackerUrl', u+'matomo.php']);
    _paq.push(['setSiteId', '10']);
    var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
    g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
<!-- End Matomo Code -->

Beautimous. It just works!