---
title: "hugo-mcp Cloudflare plugin: smart cache purge"
description: "Deep-dive on the hugo-mcp v2.0 Cloudflare plugin: CDN cache purge with 3 modes (full, partial, smart). The smart mode auto-computes related URLs to invalidate (sitemap, RSS, taxonomies, home), saving 95% of invalidations without losing cache consistency."
url: "https://www.arleo.eu/en/posts/hugo-mcp-plugin-cloudflare/"
language: "en"
datePublished: "2026-05-14T09:43:19+02:00"
dateModified: "2026-06-28T20:47:32Z"
tags: ["hugo","mcp","cloudflare","cdn","cache","purge","performance","python"]
categories: ["infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# hugo-mcp Cloudflare plugin: smart cache purge
Deep-dive on the hugo-mcp v2.0 Cloudflare plugin: CDN cache purge with 3 modes (full, partial, smart). The smart mode auto-computes related URLs to invalidate (sitemap, RSS, taxonomies, home), saving 95% of invalidations without losing cache consistency.



## TL;DR

The Cloudflare plugin in [hugo-mcp v2.0](https://github.com/jmrGrav/hugo-mcp/releases/tag/v2.0.0) implements 3 cache purge modes (`full`, `partial`, `smart`). The `partial` mode computes the linked URLs to invalidate (canonical + sitemap + RSS + listing + home) to preserve 95% of the CDN cache on every modification. Concretely: 6 URLs purged instead of wiping everything. This post details the computation, the pitfalls, and why `smart` became the default.

<!--more-->

This post complements [Plugin-system architecture](/en/posts/hugo-mcp-plugins-architecture/) by zooming in on the most sophisticated of the 3 plugins shipped in v2.0.

## The problem with full purge

Cloudflare caches HTML aggressively. Without purging, after each article modification visitors see the old version for hours. The naive reflex: `purge_everything` on every save.

```python
# Naive approach — works but wasteful
async def on_page_event(self, event_type, urls, context):
    await client.post(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache",
        json={"purge_everything": True},
    )
```

The problem: `purge_everything` invalidates **all** pages on the domain. If I fix a typo in an article, I lose cache for:

- The home (re-rendered on next visitor, NUC CPU cost)
- The other 130+ article pages (same)
- The sitemap, RSS, robots.txt (re-generated on first request)
- The CSS/JS assets going through Cloudflare

On a blog with 100+ visitors/day, that means **the NUC re-works nginx for 5-10 minutes** after each save, while Cloudflare repopulates its edges. CPU spike, latency uptick, cache debt.

## Targeted purge

Cloudflare offers a `files: [...]` API that invalidates **only** the listed URLs (max 30 per call). That's what's needed.

But when I modify `/posts/foo/`, which URLs should I purge exactly?

Not just `/posts/foo/`. If I purge only that, I leave in cache:

- The **home** which lists the modified article (with its old title/summary)
- The **`/posts/` listing** (same)
- The **sitemap.xml** which mentions the `lastmod` date
- The **RSS `/index.xml`** which shows the old summary
- The **taxonomy** pages (`/tags/python/`, `/categories/infrastructure/`) which aggregate

So a modification = an **invalidation graph** to compute.

## Computing linked URLs

Here's the logic of the `cloudflare` plugin in `partial` mode:

```python
def _compute_related_urls(self, urls: list[str], context: dict) -> list[str]:
    """Compute linked URLs to invalidate for a given modification."""
    base = self.base_url.rstrip("/")
    related = set()
    
    # Always invalidate home and sitemap
    related.add(f"{base}/")
    related.add(f"{base}/sitemap.xml")
    related.add(f"{base}/index.xml")  # RSS root
    
    # If URL is in /posts/, invalidate listing and section RSS
    for url in urls:
        if "/posts/" in url:
            related.add(f"{base}/posts/")
            related.add(f"{base}/posts/index.xml")
        if "/en/posts/" in url:
            related.add(f"{base}/en/posts/")
            related.add(f"{base}/en/posts/index.xml")
            related.add(f"{base}/en/")
    
    return sorted(related)
```

For an `update_page` on `/posts/migration-grav-hugo/`, that gives 6 URLs:

```
https://www.arleo.eu/
https://www.arleo.eu/posts/
https://www.arleo.eu/posts/migration-grav-hugo/
https://www.arleo.eu/sitemap.xml
https://www.arleo.eu/index.xml
https://www.arleo.eu/posts/index.xml
```

6 URLs invalidated instead of 130+. Cache for all other articles is preserved.

## The 3 modes

```yaml
# config/plugins.yaml
cloudflare:
  enabled: true
  mode: smart                      # full | partial | smart
  api_token_env: CF_API_TOKEN
  zone_id: d2f7807c2c5b7c9737da45f538072423
  base_url: "https://www.arleo.eu"
```

### `full` mode

Legacy behavior. `purge_everything` on every event. Safe but costly. Use case: compat with existing workflows, or debug when suspecting a broader cache issue.

### `partial` mode

`purge_everything` never triggered. On every event, compute linked URLs and API call with `files: [...]`. Economical, targeted.

Accepted risk: if a modification has an impact not anticipated (global footer change via shortcode, modifying a tag affecting all pages with that tag), partial mode may leave stale pages in cache. For these exceptions, manually switch to `full` for the modification, then back to `partial`.

### `smart` mode (default)

The compromise that seemed right after a few days in production:

```python
if mode == "smart":
    effective_mode = "full" if event_type == "deleted" else "partial"
```

`partial` on `created` and `updated`, `full` on `deleted`.

Why `full` on `deleted`? Because a deletion can affect many aggregations (the tag page loses an article, the home too, the "related articles" at the bottom of each article may reference the deleted one). It's rare in practice (I rarely delete) but when it happens, might as well purge broadly.

## Production measurements

On the last `update_page` (publishing this post):

```json
{
  "plugin": "cloudflare",
  "success": true,
  "mode": "partial",
  "urls_purged": 6,
  "duration_ms": 337,
  "cf_response": {"success": true, "errors": []}
}
```

337 ms to purge 6 URLs. `full purge` also takes 200-300 ms on the Cloudflare API side (the actual work happens on edges afterwards). So latency isn't the discriminating criterion — it's the **cache economy** that matters.

When `update_page` Cloudflare returns, the cache of the 130+ unmodified pages is intact, and visitors keep hitting the edge without re-hitting the NUC.

## Pitfalls encountered

### Cloudflare doesn't purge trailing-slash URLs if submitted without

The Cloudflare API is sensitive to exact format. `https://www.arleo.eu/posts/foo` and `https://www.arleo.eu/posts/foo/` are **two different cache entries**. Hugo generates URLs with trailing slash. So the plugin must always submit with trailing slash.

### 30 URLs per call limit

The `files: [...]` API accepts max 30 URLs per call. For a personal blog with 6 URLs per modification it's fine, but if you build a system invalidating hundreds of URLs (taxonomy refactor, etc.), you need to **chunk**.

```python
async def _purge_partial(self, urls: list[str]) -> dict:
    CHUNK_SIZE = 30
    results = []
    for i in range(0, len(urls), CHUNK_SIZE):
        chunk = urls[i:i+CHUNK_SIZE]
        response = await client.post(api_url, json={"files": chunk})
        results.append(response.json())
    return {"success": all(r.get("success") for r in results), "chunks": len(results)}
```

### CF_API_TOKEN must have minimal Cache Purge scope

Not Zone:DNS:Edit, not Zone:Workers, just `Cache Purge`. If the token has too many scopes, you needlessly expand the surface in case of leak. Best practice: generate a dedicated token for this plugin with only this scope.

### Systemd whitelist for Cloudflare IPs

The Cloudflare API is reachable on `api.cloudflare.com` running on the usual Cloudflare IPs (104.16.0.0/12, 172.64.0.0/13). If your hugo-mcp runs with `IPAddressDeny=any` + `IPAddressAllow` (security sprint C1-C10 case), add:

```
IPAddressAllow=104.16.0.0/12
IPAddressAllow=172.64.0.0/13
```

Otherwise the plugin times out silently.

## Structured audit log

Each purge emits a structlog event that flows to BetterStack:

```json
{
  "ts": "2026-05-09T23:50:40Z",
  "event": "plugin.cloudflare.purge",
  "level": "info",
  "mode": "partial",
  "urls_purged": 6,
  "duration_ms": 337,
  "trigger": "mcp.update_page",
  "route": "/posts/hugo-mcp-plugins-architecture/",
  "success": true
}
```

This allows measuring CF API latency over time, detecting unexpected `full` modes, correlating purge failures with visible visitor-side failures.

## Benefits of smart mode

Three months of hindsight aren't available yet (I just implemented this), but here's the prospective analysis:

1. **Cache hit rate maintained**: the 130+ unmodified pages stay fresh in Cloudflare cache. Visitors hit the edge, not the NUC.
2. **NUC CPU stable**: no re-rendering spike after every save.
3. **Stable latency**: no perceptible "rebuild cache" period for visitors.
4. **Controlled Cloudflare cost**: fewer API calls.

Downside: if we miss a URL in the "linked" computation, it stays in stale cache. To mitigate, I can always do a `build_site` MCP that switches to `full` mode manually when I sense something isn't refreshing.

## Backlog

What's still missing:

- **Auto-detect impacted taxonomies**: if a post has the `python` tag, the modification should also invalidate `/tags/python/`. Not done yet, I purge manually if I touch tags.
- **Cloudflare → BetterStack webhook**: to confirm on the CDN side that purge has propagated (currently we trust the API response).
- **"Explain" mode**: a dry-run that shows which URLs would be purged, without calling the API. Useful for debug.

## Conclusion

The Cloudflare plugin in `smart` mode saves ~95% of cache on each modification, for ~150 lines of Python implementation cost. The invalidation graph computation is simple: home + sitemap + RSS + listings + canonical. And `full purge` remains there in degraded mode for exceptional cases.

Full code in [plugins/cloudflare/plugin.py](https://github.com/jmrGrav/hugo-mcp/tree/main/plugins/cloudflare) on GitHub. For the plugin-system architecture hosting this plugin, [Plugin-system architecture](/en/posts/hugo-mcp-plugins-architecture/). For the broader migration context that justified all this, [Grav to Hugo migration](/en/posts/migration-grav-hugo/).


## Tags

- hugo
- mcp
- cloudflare
- cdn
- cache
- purge
- performance
- python

## Categories

- infrastructure
