A Better Cache Invalidation Solution for Ghost

If you've ever run a Ghost site behind a content delivery network (CDN), you've probably encountered the cache invalidation problem: how do you make sure that the embarrassing typo you missed actually gets updated everywhere? Or in technical terms: what's the best way of invalidating your CDN cache?

Ghost provides webhook functionality for content updates, which many use to trigger cache purging. The idea: your content updates, Ghost tells your CDN. Your CDN deletes the cache – everybody is happy.

Ghost Webhooks
Trigger events when something happens in Ghost using webhooks configured through the Ghost Admin user interface. Find out more on Ghost Docs πŸ‘‰

Update: Murat from Synaps Media pointed out that Ghost does have a site.changed webhook event that can detect theme changes and other site-wide modifications. I missed this in my initial research, and I want to be transparent about that oversight. This event can indeed help with detecting some of the updates I mentioned aren't caught by regular content webhooks.

However, I still think that there is a better way than using webhooks πŸ‘‡


While the site.changed webhook is helpful, it has some limitations - it only tells you that something changed, but not what exactly changed or which URLs need to be invalidated. For precise cache management, you often need more specific information about which content was affected. You can do this with the post.published.edited webhook, but then you'd also need to set up the post.publishedpost.unpublished,... webhooks. Not very practical.

From the perspective of a managed hosting provider, I also don't want to set up webhooks for every single customer. Cache invalidation is a core feature of Magic Pages, and should be part of the infrastructure as a whole, in my eyes.

Ghost webhooks are stored in the application database – usually MySQL. A possible migration to a different hosting provider would keep these intact. That just creates noise. And yes, I have seen this. One client that moved to Magic Pages, provided me with a database backup of their site, which was previously hosted with a different provider. After a few days, we figured out that the site was triggering webhooks to that hosting provider's backend. No harm done on my end. Post updates (yeah, just these - the site.changed webhook was actually not used) aren't very sensitive information. However, the database backup included their backend endpoint and a signing secret.

Thankfully, Ghost has another built-in mechanism for signaling when content changes – one that provides more detailed information about what exactly needs updating: the X-Cache-Invalidate header.

The Missing Piece in Ghost's Cache Invalidation

Around a year ago while implementing Bunny.net's full-page CDN at Magic Pages, I discovered this header by chance. I found it in an old thread on the Ghost forum – and a Google search then led me to the initial discussion on Github:

Hosting: Cache invalidation headers Β· Issue #570 Β· TryGhost/Ghost
So that our hosting platform, and anyone else who wants to use heavy caching, can correctly clear the cache when new content is added, we need to ensure that the API returns cache invalidation head…

Back in 2013, Ghost implemented the X-Cache-Invalidate header for their new hosted platform, now known as Ghost(Pro). They specifically mentioned "heavy caching" – and that's exactly what most people want nowadays anyway.

Headers are little bits of information that are sent in every request on the internet (well..most...but let's not get hung up on details). When you opened this page, your browser sent a request, and the Magic Pages server then sent a response.

Both the request and the response have headers attached. And certain responses from a Ghost server contain this special X-Cache-Invalidate header. In most cases, these are actions that update resources. This can be a theme change, a post update, or the new post being published. Here's an example:

This is a screenshot of the response headers when I updated a post here on the Magic Pages blog. You can see loads of CDN-related headers, but also the X-Cache-Invalidate header.

Quite side note: technically, this header is not documented anywhere in Ghost's official documentation. It could therefore change at any moment, without prior warning. However, it's been stable for many years, and Ghost(Pro) itself relies on it.

How can I use this?

Using this header is simple in theory, but a bit tricky in practice. In theory, you want your server to monitor traffic, and shout "HEY, UPDATE!", when it spots the usage of the header.

In practice, it most likely means that you'll need some sort of proxy.

So far, on Magic Pages, I have been using a proxy that was specific to Bunny.net:

GitHub - magicpages/ghost-bunnycdn-perma-cache-purger
Contribute to magicpages/ghost-bunnycdn-perma-cache-purger development by creating an account on GitHub.

With the upcoming implementation of a Typesense-based search for customers on the Pro plan, I needed to make sure that I can also let Typesense know about updated content. Setting up individual webhooks for each site would have been a maintenance nightmare and created too many potential points of failure.

A More Complete Solution

To solve this, I've released the Ghost Cache Invalidation Proxy – a completely versatile little tool that lets you do whatever you want.

GitHub - magicpages/ghost-cache-invalidation-proxy
Contribute to magicpages/ghost-cache-invalidation-proxy development by creating an account on GitHub.

The concept is straightforward: you place this proxy between your Ghost CMS and the internet. It silently monitors traffic, looking for that X-Cache-Invalidate header. When detected, it forwards the invalidation patterns to any webhook endpoint you configure - whether that's Bunny.net, Cloudflare, or your custom cache service.

Compared to the standard Ghost webhook approach (even with the site.changed event), this provides a more precise information about what exactly changed, and a unified approach that catches all types of updates in one consistent way.

This approach is particularly valuable for managed hosting providers (like Magic Pages) because it allows centralized cache management without touching customer databases, avoids webhook configuration clutter that persists through migrations, provides a consistent experience across all customer sites, and works at the infrastructure level, separate from the application itself.

It's also highly configurable, allowing you to format the webhook requests exactly as your CDN requires.

When Should You Use This?

If you've been wondering which specific URLs to invalidate after updates, or you need more granular control than just knowing "something changed," this approach provides that precision.

Admittedly, for smaller personal blogs, the extra component might be overkill - occasional manual cache purging or simple webhook-based invalidation is manageable. But for bigger publications where stale content is unacceptable, or sites with frequent theme or structural updates, this provides a more complete and precise solution.

The proxy is packaged as a Docker image for simple deployment. This is a basic Docker Compose file that could get it running:

version: '3.8'

services:
  db:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
      MYSQL_PASSWORD: ghostpassword
    volumes:
      - mysql_data:/var/lib/mysql
    command: --default-authentication-plugin=mysql_native_password
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$MYSQL_ROOT_PASSWORD"]
      interval: 5s
      timeout: 5s
      retries: 10

  ghost:
    image: ghost:5
    restart: always
    depends_on:
      db:
        condition: service_healthy
    environment:
      url: https://yourdomain.com
      database__client: mysql
      database__connection__host: db
      database__connection__user: ghost
      database__connection__password: ghostpassword  # Match MySQL password
      database__connection__database: ghost
      # Uncomment and set mail configuration if needed
      # mail__transport: SMTP
      # mail__options__service: 
      # mail__options__host: 
      # mail__options__port: 
      # mail__options__auth__user: 
      # mail__options__auth__pass: 
    volumes:
      - ghost_data:/var/lib/ghost/content

  cache-invalidation:
    image: magicpages/ghost-cache-invalidation-proxy:latest
    restart: always
    depends_on:
      - ghost
    ports:
      - "80:4000"
    environment:
      - GHOST_URL=http://ghost:2368
      - PORT=4000
      - DEBUG=true
      - WEBHOOK_URL=https://api.example.com/invalidate
      - WEBHOOK_METHOD=POST
      - WEBHOOK_SECRET=your_secret_key
      - WEBHOOK_HEADERS={"Custom-Header": "Value"}
      - WEBHOOK_BODY_TEMPLATE={"urls": ${urls}, "timestamp": "${timestamp}", "purgeAll": ${purgeAll}}

volumes:
  ghost_data:
  mysql_data: 

What you'd still need is a reverse proxy that, ideally, also handles SSL certificates for you. The basic idea, however, is that you expose the Ghost Cache Invalidation Proxy rather than Ghost directly.

If you're hosting with Magic Pages, all of this is already handled for you behind the scenes – no webhooks to configure, no database clutter in backups, just reliable cache invalidation that works across all content types. For those running their own Ghost installations who want more reliable and precise cache invalidation without the hassle of multiple webhook configurations, the full documentation is available on GitHub.

Customer Stories

Built by Ghost publishers like you

From personal blogs to global publications, see what others are building with Magic Pages.

Screenshot of Ellie Mathieson's website

Ellie Mathieson

Digital Storefront
Screenshot of Big Idea Bible

Big Idea Bible

Personal Blog
Screenshot of Bento

Bento

Ghost Theme