Contents

hugo-mcp Cloudflare plugin: smart cache purge

TL;DR

The Cloudflare plugin in hugo-mcp v2.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.

This post complements Plugin-system 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.

# 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:

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

# 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:

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):

{
  "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.

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:

{
  "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 on GitHub. For the plugin-system architecture hosting this plugin, Plugin-system architecture. For the broader migration context that justified all this, Grav to Hugo migration.