---
title: "Purge your CDN from wp-admin — with no secrets in the container"
date: 2026-07-09
modified: 2026-07-09
permalink: "https://cloudpress.com/cdn-purge-from-wp-admin-container-actions/"
author: "Kris Watson"
excerpt: "A one-click “Purge CDN cache” button inside wp-admin — with zero privileged credentials in the container. The security engineering behind CloudPress Container Actions."
featured_image: "https://cloudpress.com/wp-content/uploads/2026/07/cs-cdn-purge-featured.svg"
categories:
  - name: "Product"
    url: "/category/product.md"
---

# Purge your CDN from wp-admin — with no secrets in the container

[Blog](/blog-news/)

[Product](https://cloudpress.com/category/product/)

 

# Purge your CDN from wp-admin — with no secrets in the container

A one-click “Purge CDN cache” button inside wp-admin — with zero privileged credentials in the container. The security engineering behind CloudPress Container Actions.



![](https://secure.gravatar.com/avatar/c0b971812a9428ec010baa5df71aeb2cdc574649abfa9443daf02824d30ae3cd?s=48&d=mm&r=g)

Kris Watson





09 Jul 2026

 9 min read

 

 

 

 ![The trust boundary between the untrusted WordPress container and the server-side chain to the Bunny CDN.](https://cloudpress.com/wp-content/uploads/2026/07/cs-cdn-purge-featured.svg)   > **Coming soon.** This post previews a feature we’re actively building. Names, request shapes, and limits described here are the design as it stands and may change before release.

We’re building a **“Purge CDN cache”** button for WordPress. It sounds mundane, and it turned out to be a nice piece of security engineering. Click it in wp-admin (or call it from your own code) and your site’s Bunny CDN cache clears, either the whole zone or a list of paths.

The interesting part is what sits behind the button: nothing sensitive. The WordPress container that triggers the purge holds **zero** privileged credentials. No Bunny API key. No CloudPress service account. Nothing an attacker could steal to purge someone else’s cache, or reach Bunny at all.

This is also the first consumer of a new, generic capability we’re calling **Container Actions**: a way for a container to request a named action on its own environment. So this is two stories at once: a feature (CDN purge) and the architecture that makes it safe (and that we’ll reuse for everything after it). And because the channel it rides on is part of ComputeStacks, **all of it is open source**.

## The threat model, stated up front

A managed WordPress container runs **arbitrary customer code**: themes, plugins, whatever the site owner installs. From the platform’s point of view, that makes the container effectively hostile. Anything valuable you put inside it, you should assume can be read and exfiltrated by a malicious or compromised plugin.

That single assumption kills the obvious implementation. You *could* drop a CDN API token into the container and let WordPress call Bunny directly. But that token would sit on disk, in an env var, in a config table, reachable by any plugin, and it would authorize purges (and worse) far beyond the one site. A stolen CDN credential is a fleet-wide problem.

So the rule is simple: **privileged credentials never enter the container.** The container is only ever allowed to *ask*. Everything that requires real authority happens server-side, in code the customer can’t reach.

The one thing the container legitimately holds is a credential the platform already gave it: its **metadata Bearer token**. Everything builds on that.

## The trust anchor: an identity the container can’t forge

Every managed container already gets a metadata Bearer (injected by the platform as `METADATA_AUTH`) that it uses to read and write its own metadata from the on-node agent (cs-agent). We reuse exactly that credential, and nothing else, as the trust anchor for actions. When a container calls the agent, the agent derives identity like this:

Deriving project identity from the metadata BearerBearer &lt;token&gt;PRESENTED BY CONTAINER→sha256(token)AGENT→tenant lookupCONTROL.DB→project\_idSTAMPED IDENTITYThe critical property: **the project identity comes from the token, never from the request.** Not from the JSON body, not from a URL path, not from the source IP. A malicious plugin can put anything it likes in the request body, yet it still cannot claim to be a different project, because it doesn’t hold another project’s token. The tenant boundary is cryptographic.

We deliberately kept this identity *coarse*: the agent stamps a `project_id` and nothing finer (no container id, no service id, no source-IP-to-container reverse lookup). Any container in a project is allowed to act on that project, and cross-project access is already blocked by the token. If a future action ever needs to name a specific resource, that becomes a **caller-supplied parameter**, which is safe, because a caller can only ever name resources inside its own already-isolated project. The worst a customer can do is affect themselves.

Here’s the whole container-side API. Note what’s absent: there’s no place to put a secret, because there’s no secret to put:

```
POST /v1/actions
Authorization: Bearer <metadata token>
Content-Type: application/json

{ "action_type": "cdn_purge", "params": { "mode": "all" } }

# or, purge specific paths (wildcards allowed):
{ "action_type": "cdn_purge",
  "params": { "mode": "paths", "paths": ["/blog/post-1", "/assets/*"] } }
```

The agent stamps the `project_id` from the token, records the request, and returns `202 Accepted`. It does **not** interpret `action_type` — it has no idea what `cdn_purge` means. More on why that’s deliberate below.

## “Up = truth”: an action is a fact, not a command

Here’s the architectural crux, and the reason this design doesn’t quietly undermine the rest of the platform.

ComputeStacks runs on a simple law: **down = intent, up = truth.** The controller sends desired state *down* to nodes (its authority); nodes report observed reality *up* (their authority). Direction of flow equals direction of authority.

A container-initiated *command* travelling upward (“hey controller, go purge Bunny”) would invert that. Suddenly the least-trusted thing in the system (customer PHP) is issuing commands to the most-trusted (the controller that owns billing and the CDN relationship).

So we don’t model it as a command. We model it as a **fact**:

> *A container in project P requested action A with parameters X.*

That fact travels up like any other observed truth. The **controller**, which owns the business relationship with the CDN, sees the fact and decides, in its *own* authority, whether to act on it. The container asks; the controller acts. Authority never flows the wrong way.

This reframe pays off immediately: there’s **no custom “actions” transport** to build. The action-request is just another entry on the node’s existing up-channel. And a cache purge is naturally an *event* (“empty the cache once”), not a *desired state* to reconcile toward, so the event model fits.

Worth noting: both halves of that up-channel, the on-node agent and the controller, are open source, part of ComputeStacks (AGPLv3). The mechanism carrying these facts isn’t a black box; it’s public code you can read and run yourself.

## How the fact travels up: the changelog

The up-channel is built on a primitive coming in our next cs-agent release: an append-only **changelog** in the agent’s embedded SQLite, with a **global, monotonic sequence number** per node. Every node-owned state change is written as a full snapshot, in the *same transaction* as the change itself, so the log and the state can never disagree. The controller consumes it by pulling incrementally:

```
GET /v1/admin/changelog?since=<seq>&limit=<n>&entity_type=action_request

{ "entries": [
  { "seq": 1, "entity_type": "action_request", "entity_id": "<uuid>",
    "project_id": "<deployment id>", "op": "upsert",
    "payload": { "action_type": "cdn_purge", "params": { "mode": "all" },
                 "status": "pending" } }
] }
```

Two design choices matter here:

- **The controller projects, it doesn’t replay.** Snapshots re-emit at a higher `seq` whenever a row changes. A consumer that fired a handler on every row it saw would re-purge on every re-emission. Instead the controller blind-upserts each snapshot into a local table keyed by entity id, and dispatches off *state transitions* in that table. Re-seeing a row is a no-op.
- **Delivery is at-least-once, with dedupe.** A crash or a retry can deliver the same request twice. Each action carries a stable, agent-generated id, and the controller dedupes on it. A cache purge is idempotent anyway, so a double-delivery is harmless to correctness, and dedupe is there to save wasted work.

## Four hops, four authentications

This is the heart of the security story. The purge crosses four trust domains between the browser and Bunny, and **the credential never travels with it.** Each hop re-authenticates on its own terms, with a credential scoped to that hop:

Four hops, four authenticationsUNTRUSTEDruns customer codeSERVER-SIDE · CUSTOMER CAN’T REACHContainerWordPressagentnodecontrollerComputeStacksCloudPressowns BunnyBunnyCDNtenant Bearer→ project\_idadmin Bearer(→ mTLS)API key + IPno OAuth scopeAccessKeyEverything right of the dashed boundary is server-side — the credential for each hop stops at that hop.HopFrom → ToAuthenticated by1Container → agentTenant **Bearer**; agent stamps `project_id` (un-spoofable)2Agent → controllerPer-node **admin Bearer** (designed to become **mTLS** node-identity certs)3Controller → CloudPress`system_managed` **API key** + `X-Auth-Account`, gated by an **IP allowlist**, **no OAuth scope**4CloudPress → BunnyBunny **AccessKey**, held only in CloudPress, used only server-sideA couple of hops deserve a note:

- **Hop 3** mirrors the model our webhook endpoints already use: a `system_managed` API key with an IP allowlist and, importantly, **no OAuth scope**. Scope enforcement fail-closes OAuth tokens on these routes, so a leaked customer OAuth token can’t reach them.
- **Hop 2** is network-trusted today (the controller re-validates the stamped identity itself), and it’s built as a *seam*: it’s designed to swap to mTLS with per-node client-certificate identity with no change at the call sites.

## One project id, four layers

If the container never names a site or a pull zone, how does the purge find the right cache? Through a single identity that threads all four layers:

One project id across four layersproject\_idCS-AGENT==Deployment#idCOMPUTESTACKS==Site.cs\_project\_idCLOUDPRESS→Site.bunny\_idCDN ZONEBecause ComputeStacks models **one Site per Project**, resolving `project_id` to a CDN zone is unambiguous, and it happens entirely server-side at CloudPress. The container supplies a project identity it can prove and a purge mode; CloudPress does the rest. (Under the hood there’s a small representation detail: the agent stores the id as text and the platform as an integer, guarded by a test so the join can’t silently drift.)

## Talking to Bunny: async and throttled

Two purge modes: the whole zone in a single call, or a list of paths, one request per URL, since Bunny has no batch endpoint (wildcards apply when a path ends in `/` or contains `*`).

Bunny’s rate limits are real and asymmetric: exact purges refill quickly, wildcard purges far slower. And a customer can press the button as often as they want. So purges run **async, throttled, and coalesced** server-side, never looped from a request thread, with rate limiting layered across three points: the agent, the controller’s per-project budget, and CloudPress’s own Bunny token-bucket.

## Open core: a generic contract, in the open

ComputeStacks is open core, and this feature falls exactly on that line. The generic capability lives in the open-source agent and controller (both AGPLv3): the container-facing `POST /v1/actions`, the changelog up-channel, the un-spoofable identity stamping, and a pluggable **action registry**. That code defines no actions of its own; `cdn_purge` is registered on top of it.

Keeping the contract generic and open has a practical payoff: the channel and the CDN feature get built on independent tracks. “A container can request a named action, with its identity stamped un-spoofably” is useful with or without a CDN, so the agent and controller work lands first, and the purge handler is written against nothing but the request/response contract. It’s **contract-first**: freeze the seam (request shape, response-to-state mapping, idempotency) and let both sides move behind it independently.

The same discipline covers transport: an HTTP push-plus-reconcile channel today, gRPC with mTLS node identity later, with no change at the call sites. And because the framework is open, it doubles as a general extension point: anyone running ComputeStacks can register their own container actions in their own engine without touching core. CDN purge is simply the first one we’ve written.

---

That’s the feature and the architecture in one: a one-click purge with nothing sensitive standing behind it. Container Actions arrives soon. CDN cache purge is the first of them, and the rest run on the same foundation.



 

  ![](https://secure.gravatar.com/avatar/c0b971812a9428ec010baa5df71aeb2cdc574649abfa9443daf02824d30ae3cd?s=48&d=mm&r=g)

Kris Watson





   ## More from the team

 [View all articles](/blog-news/) 

- [Product](https://cloudpress.com/category/product/)
    
     [![New dashboard version live](https://cloudpress.com/wp-content/uploads/2026/06/Screenshot-2026-06-12-at-13.11.42.png)](https://cloudpress.com/new-dashboard-version-live/) 
    
    ### [New dashboard version live](https://cloudpress.com/new-dashboard-version-live/)
    
    After several months of hard work, it’s finally here: the new version of our dashboard is live at my.cloudpress.com! In this blog…
    
    
    
    12 Jun 2026
    
     9 min read
- [Product](https://cloudpress.com/category/product/)
    
     [![CloudPress 2025 Year in Review](https://cloudpress.com/wp-content/uploads/2026/01/Log-viewer.png)](https://cloudpress.com/cloudpress-2025-year-in-review/) 
    
    ### [CloudPress 2025 Year in Review](https://cloudpress.com/cloudpress-2025-year-in-review/)
    
    2025 has been a busy year for the team at CloudPress. We focused heavily on building tools to help our agency and…
    
    
    
    19 Jan 2026
    
     9 min read
- [Product](https://cloudpress.com/category/product/)
    
     [![DNS editor now available!](https://cloudpress.com/wp-content/uploads/2025/07/dnseditor.png)](https://cloudpress.com/dns-editor-now-available/) 
    
    ### [DNS editor now available!](https://cloudpress.com/dns-editor-now-available/)
    
    Tired of having to manage your DNS in a seperate place from your CloudPress site? Us too. That is why we have…
    
    
    
    14 Jul 2025
    
     9 min read

 

   Want to learn more?

## Complete managed Hosting for WordPress, engineered for the AI era

 [Try it now →](https://my.cloudpress.com/try) 

 [Partner with us](#)