6 min read

Automate Cloudflare Cache Purging for Ghost with Docker and Webhooks

In my previous Cloudflare performance article, I enabled HTML caching for my self-hosted Ghost blog. That made a big difference: pages were served much faster, and my server had to do a lot less work.

But there was one downside.

Because Cloudflare now caches the public HTML pages, new content does not always appear immediately. A newly published Ghost post is live right away, but the homepage can still show the old cached version. That means visitors may not see the new article until the cache expires or gets purged.

Manually purging the Cloudflare cache works, but that is not really the point of self-hosting for me. If something is predictable and repeatable, I would rather automate it.

So in this article, I’ll show how I built a small Dockerized webhook service that listens for Ghost post update events and then purges the relevant Cloudflare cache automatically.


Table of Contents


Why I Automated Cache Purging

HTML caching made my Ghost blog much faster, but it also created one important problem: cached pages can become outdated.

I did not want to manually purge the cache after every change, so I automated it with this flow:

Ghost webhook → Node.js cache purger → Cloudflare API

When a post changes in Ghost, Ghost sends a webhook to my cache purger container. The cache purger then tells Cloudflare which cached URLs should be cleared.

The URLs that are always purged come from the configuration list. In my setup, that list includes the homepage, because the homepage changes whenever I publish or update content.

For updates to existing posts, the post page itself can also become outdated in the cache. That is why the cache purger can optionally check the Ghost webhook payload for a post slug and purge that specific post URL too.

So the purger has two parts:

  1. Purge the configured paths from PURGE_URLS.
  2. Optionally purge the specific updated post URL from the Ghost webhook payload.

This keeps the setup flexible: you decide which pages should always be purged, and the container can also handle the changed post URL when needed.


What You Need Beforehand

Before building the webhook service, there are two Cloudflare values you need to have ready:

  • a Cloudflare API token
  • your Cloudflare Zone ID

Cloudflare API Token

The Node.js service needs permission to purge the Cloudflare cache. For that, I created a dedicated Cloudflare API token instead of using my global API key.

In Cloudflare, go to:

Manage Account → Account API Tokens → Create Token

When creating the token, add a policy with the Purge permission and create the token.

Later, this token will be passed to the Docker container as an environment variable:

CLOUDFLARE_API_TOKEN=your-cloudflare-api-token

Cloudflare Zone ID

The service also needs the Cloudflare Zone ID.

The Zone ID tells the Cloudflare API which domain it should purge the cache for.

You can find it by opening your domain in Cloudflare and going to:

Overview → API → Zone ID

This will also be passed to the container as an environment variable:

CLOUDFLARE_ZONE_ID=your-cloudflare-zone-id

Running the Cache Purger with Docker Compose

For the cache purger itself, I built a small Node.js service and packaged it as a Docker image.

The code is available on GitHub, and I also published the image on Docker Hub so it can be used directly in Docker Compose.

The service has one simple job:

  1. receive a webhook from Ghost
  2. verify that the request is valid
  3. call the Cloudflare API
  4. purge the cached configured URLs
  5. optionally purge the specific updated post URL from the Ghost payload

I added the container to my existing Docker Compose setup:

ghost-cache-purger:
  image: marink1999/ghost-cloudflare-cache-purger:latest
  ports:
    - "127.0.0.1:3001:3001"
  container_name: ghost-cache-purger
  environment:
    CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
    CLOUDFLARE_ZONE_ID: ${CLOUDFLARE_ZONE_ID}
    SITE_URL: ${SITE_URL}
    PURGE_PATH: ${PURGE_PATH}
    PURGE_URLS: ${PURGE_URLS}
    PORT: ${PORT}
    GHOST_WEBHOOK_SECRET: ${GHOST_WEBHOOK_SECRET}
    PURGE_UPDATED_POST_URL: ${PURGE_UPDATED_POST_URL}
  networks:
    default:
      aliases:
        - ghost-cache-purger.internal
  restart: unless-stopped

The environment variables are loaded from my .env file:

CLOUDFLARE_API_TOKEN=your_cloudflare_api_token
CLOUDFLARE_ZONE_ID=your_cloudflare_zone_id
SITE_URL=https://example.com
PURGE_PATH=/purge-cache
PURGE_URLS=/,/about/,/contact/
PORT=3001
GHOST_WEBHOOK_SECRET=your_shared_header_secret
PURGE_UPDATED_POST_URL=true

SITE_URL is the base URL of your Ghost site. The cache purger combines it with the paths from PURGE_URLS to build the full URLs that should be purged.

For example:

SITE_URL=https://example.com
PURGE_URLS=/,/about/,/contact/

This tells the cache purger to purge:

https://example.com/
https://example.com/about/
https://example.com/contact/

PURGE_UPDATED_POST_URL is optional and defaults to false. When it is set to true, the cache purger can also use the Ghost webhook payload to purge the specific post URL that changed.

For example, if the webhook payload contains this slug:

my-updated-post

the cache purger can build and purge:

https://example.com/my-updated-post/

So the cache purger can purge two types of URLs:

  1. The configured paths from PURGE_URLS
  2. Optionally, the changed post URL from the Ghost webhook payload

For this direct internal setup to work, the Ghost container and the cache purger container need to be on the same Docker network. In the next section, I will connect Ghost to this container using the internal Docker network alias.

Important for self-hosted Ghost setups

Recent Ghost versions block webhook URLs that resolve to internal/private IP addresses by default. Because this setup uses an internal Docker network URL, I had to allow internal webhook IPs in my Ghost container:

environment:
  security__allowWebhookInternalIPs: "true"

If you want to see how my Ghost container is set up, I covered that in my self-hosted Ghost Docker setup guide.


Creating the Ghost Webhook

Now that the cache purger container is running, Ghost needs to know where to send the webhook.

In Ghost, go to:

Settings → Advanced → Integrations

Here I created a new custom integration for the cache purger. Inside that integration, I added a webhook for the event:

Post updated

The Post updated event is also triggered when I publish a new post in my setup. I am not completely sure if this behavior is the same for every Ghost version, so it is worth testing this in your own setup.

Then I used this internal webhook URL:

http://ghost-cache-purger.internal:3001/purge

Ghost webhook setup using Docker internal network alias

The hostname ghost-cache-purger.internal is the Docker network alias of the cache purger container.

I used this alias because Ghost validates webhook target URLs, and in my case the plain container name was not enough. Adding .internal made it pass validation.

The last part of the URL:

/purge

matches the PURGE_PATH value from my .env file.

The PURGE_PATH can be anything you like, as long as it matches the value in your .env file and the URL you enter in Ghost.

Ghost also gives the webhook a secret. I copied that value into my .env file:

GHOST_WEBHOOK_SECRET=your-ghost-webhook-secret

For my internal Docker network setup, the path does not have to be secret or public-friendly, because Ghost calls the cache purger inside the Docker network.

Still, the webhook secret is useful. Ghost signs the webhook request with this secret, and the cache purger uses it to verify the X-Ghost-Signature header before purging anything in Cloudflare.


Testing the Full Setup

With the container running and the Ghost webhook configured, it was time to test if everything worked.

To watch what happens, follow the logs of the cache purger container:

docker logs -f ghost-cache-purger

Then update a post in Ghost.

If everything is configured correctly, you should see a successful request in the logs.

Docker logs showing successful Cloudflare cache purge

If PURGE_UPDATED_POST_URL=true is enabled, the logs should show that the cache purger tries to purge the specific post URL as well as the configured URLs.

To confirm it even further, you can check the response headers of a URL that should have been purged:

curl -I https://yourdomain.com/your-url/

This can be one of the configured URLs from PURGE_URLS, or the updated post URL if PURGE_UPDATED_POST_URL is enabled.

After the cache has been purged, the first request should normally show a Cloudflare cache miss:

cf-cache-status: MISS

That means Cloudflare did not serve the old cached version and had to fetch a fresh one from your Ghost site.

After another request, it may become a cache hit again:

cf-cache-status: HIT

Final Thoughts

This setup solved the main caching problem I had with my self-hosted Ghost blog.

Cloudflare HTML caching made the site faster, but it also meant some pages could stay stale after publishing or updating a post. By adding a small webhook-based cache purger, I can keep the performance benefits while still making sure changed content appears quickly.

The cache purger now purges the URLs I define in PURGE_URLS. In my setup, that includes the homepage, but it could also include pages like /about/, /contact/, tag pages, author pages or pagination pages.

I also added PURGE_UPDATED_POST_URL=true, which allows the cache purger to check the Ghost webhook payload for a post slug and purge that specific post URL as well.

So the final flow is simple: Ghost sends a webhook, the cache purger verifies it, and Cloudflare purges the configured URLs. If enabled, it also purges the updated post URL from the webhook payload.

Because this setup uses an internal Docker network URL, recent Ghost versions may also require this environment variable in the Ghost container:

security__allowWebhookInternalIPs: "true"

That is enough to remove one manual step from my publishing workflow while keeping the speed benefits of Cloudflare HTML caching.