
Today we’re announcing caddy-sidekick — the full-page cache that sits in front of every WordPress site on HonestHosting. It’s a Caddy module written in Go, it runs inside the same process as PHP, and it has been serving live customer traffic for months.
It also isn’t ours from scratch, and we want to be clear about that up front.
Caddy Sidekick began life as the sidekick cache inside Stephen Miracle’s FrankenWP — a WordPress + FrankenPHP Docker image that bundles Caddy, PHP, and a small server-side page cache into one lean container. The original Sidekick is about 550 lines of Go across three files, and it does something genuinely clever: it proves that a WordPress page cache doesn’t need Varnish, doesn’t need Redis, and doesn’t need a second network hop. It can just be a Caddy middleware.
That idea is the whole foundation of what we built. We didn’t invent the approach — we inherited it, and then spent a long time finding out what breaks when you point it at a multi-tenant production fleet.
Both projects are MIT licensed. If you want the simplest possible version of this idea, go read the original — it’s short enough to read in one sitting, and it’s a better introduction to the concept than anything we could write.
Caddy handles a request by passing it down a chain of middleware handlers. Each one can inspect the request, hand it to the next handler, and then inspect or modify what comes back. It’s the same pattern as Express middleware or Rack, just in Go.
A WordPress site running FrankenPHP has a chain that looks roughly like this:

Sidekick is just another link in that chain. On a cache hit it writes the stored response and never calls the next handler at all — PHP is never invoked, WordPress never boots, the database is never queried. On a miss it calls through, wrapping the response writer to capture whatever comes back and store it on the way out.
Worth being precise about that last part: Sidekick wraps the writer once and hands it to the rest of the chain, so it has no idea which handler produced the bytes. A PHP-rendered page from php_server and a stylesheet served straight off disk by file_server take exactly the same path into the cache. Whether a given response is actually stored comes down to its status code and the bypass rules — never to which handler generated it.
Because it’s in-process, a cache hit costs a map lookup and a write to the socket. There’s no second daemon, no loopback HTTP request, no serialization boundary.
You’ll see this line in every Sidekick config:
order sidekick before rewrite
That’s not cosmetic. WordPress uses a rewrite rule to funnel every pretty permalink through /index.php. If Sidekick ran after the rewrite, every single page on the site would arrive looking like /index.php — and the cache key would collapse to one entry for the entire site. Running before rewrite means Sidekick sees /my-post/, /about/, and /category/news/ as the distinct URLs they actually are.
Every request through Sidekick ends in one of three states, and it tells you which in a response header:
X-Sidekick-Cache: HIT served from cache, PHP never ran
X-Sidekick-Cache: MISS not cached yet, response captured and stored
X-Sidekick-Cache: BYPASS deliberately not cached
You can watch it work with nothing more than curl -I:
$ curl -sI https://example.com/hello-world/ | grep -i sidekick
x-sidekick-cache: MISS
$ curl -sI https://example.com/hello-world/ | grep -i sidekick
x-sidekick-cache: HIT
That’s the entire mental model. Here’s a working config:
{
order sidekick before rewrite
}
example.com {
sidekick {
cache_dir /var/www/cache
cache_ttl 3600
# WordPress admin and API are never cached
nocache /wp-admin /wp-json /wp-login.php
purge_path /__sidekick/purge
purge_header X-Sidekick-Purge
purge_token "change-this-secret"
}
root * /var/www/html
php_server
file_server
}
Build it into Caddy with xcaddy:
xcaddy build --with github.com/honest-hosting/caddy-sidekick
And when a post changes, WordPress purges it. Sidekick ships must-use plugins that it deploys automatically (verified by SHA-256 checksum, so a tampered or stale copy gets replaced), hooked into save_post, transition_post_status, comment_post, wp_update_nav_menu, and friends. You can also purge by hand:
# everything
curl -X POST https://example.com/__sidekick/purge \
-H "X-Sidekick-Purge: your-secret-token"
# or selectively, with wildcards
curl -X POST https://example.com/__sidekick/purge \
-H "X-Sidekick-Purge: your-secret-token" \
-H "Content-Type: application/json" \
-d '{"paths": ["/blog/*", "/products/category-*"]}'
The original is ~550 lines. Ours is about 5,900 lines of production code and 8,500 lines of tests across 157 test functions, plus a Docker-based integration suite.
That ratio isn’t a boast — it’s the honest shape of the problem. Almost none of the added code is new features. It’s the accumulated cost of every way a page cache can quietly serve the wrong bytes to the wrong person.
A few of the more instructive ones:
The original builds its cache key like this:
cacheKey := encoding + "::" + r.URL.Path
Encoding and path. That’s completely fine for one site in one container. Point a catch-all Caddyfile block at multiple domains — a WordPress Multisite network, or a multi-tenant fleet like ours — and example.com/about/ and other-site.com/about/ become the same cache entry. One site’s homepage starts serving on the other’s domain.
Our key includes the request host unconditionally, plus configurable query parameters, headers, and cookies:
cache_key_queries p page paged s category tag author
cache_key_headers Accept-Encoding
cache_key_cookies wordpress_logged_in_* wordpress_sec_* wp-settings-*
This is the failure mode that keeps cache engineers up at night. A logged-in user requests a page, WordPress renders it with their name in the header, and that response ends up in a CDN edge cache where the next anonymous visitor picks it up.
Sidekick handles this in two directions at once. When a request carries a cookie that varied the cache key, the response is emitted as:
Cache-Control: private, no-store
Vary: Accept-Encoding, Cookie
Sidekick’s own cache still stores it, correctly segmented by key — but CloudFront, corporate proxies, and every other shared cache are told to keep their hands off.
The part we’re actually proud of is how that invariant is enforced. Every downstream cache header is written in exactly one function, from the same value that varied the cache key. A unit test walks the package’s abstract syntax tree and fails the build if any other function in the package writes Cache-Control, Age, Pragma, or Vary. Adding a cookie to cache_key_cookies cannot silently make a session-specific response shareable, because the compiler-adjacent guardrail won’t let a future contributor open that hole by accident.
WordPress scopes its wordpress_logged_in_<hash> cookie to /, which means a logged-in visitor sends it with every request — including every stylesheet, script, font, and image. A naive “logged in? bypass the cache” rule therefore sends all of a site’s static assets straight to origin for anyone who’s signed in.
Paths matching static_asset_regex are exempt from both the login-cookie bypass and cookie keying, so one shared entry serves everybody. The default pattern is deliberately narrow — it covers CSS, JS, fonts, and images, and it deliberately excludes PDFs, ZIPs, office documents, and video, because those are exactly the file types membership and download-gating plugins protect behind a login check. Sharing one visitor’s gated PDF with the world is a worse bug than a slow one.
Originally, every bypassed response got a blanket no-store. That conflates two very different statements, and the practical effect was that content browsers and CDNs were perfectly entitled to keep got re-fetched from origin forever.
Sidekick now picks downstream headers based on why a request skipped the cache:
| Situation | What we emit |
|---|---|
| HIT / 304 | public, max-age=<remaining ttl> + Age |
| MISS | public, max-age=<ttl> — a miss is just as cacheable, it simply wasn’t there yet |
| Bypass: /wp-admin, login cookie | private, no-store |
| Bypass: file-type policy, home page | the origin’s own Cache-Control, untouched |
| Any cookie-varied request | private, no-store — overrides everything above |
Browsers fetch video almost exclusively with Range requests, starting with a bytes=0- probe. A cache that can’t handle ranges will miss on every single one of them, forever, because a partial response can never be safely stored — the cache key doesn’t include Range, so a stored 206 would become the entry for that URL and get served to everyone as if it were the whole file.
Sidekick keeps that as a hard invariant — a partial response is never stored — and solves the problem from the other side. On a cold miss carrying a Range header, it re-issues the request upstream with Range stripped, captures the full representation for the cache, and serves the client’s requested range out of that capture. Every subsequent range request for that URL is a plain cache hit.
Since a fill reads the whole object, concurrent viewers seeking into the same cold video would each trigger their own full read. So fills are collapsed: the first request becomes the leader, and the others wait briefly and then serve from the resulting entry. Collapsing degrades open by design — on timeout, disconnect, or an uncacheable result, a follower falls through to a normal pass-through. It never blocks indefinitely, and it never fails a request that would otherwise have succeeded.
The original loads the entire on-disk cache into memory at startup and keeps every entry there. Ours is a two-tier store with independent limits on both tiers:
cache_memory_max_size 128MB # or
cache_memory_max_percent 10
cache_memory_max_count 32768
cache_memory_stream_to_disk_size 10MB # bigger than this goes straight to disk
cache_disk_max_size 10GB # or
cache_disk_max_percent 5
cache_disk_max_count 100000 # LRU eviction
cache_disk_item_max_size 100MB
And when stored bytes can go to the client unchanged, they’re streamed from an open file rather than read into memory first. A cached 34MB video is never fully resident in RAM no matter how many people are watching it — which is what makes cache_memory_stream_to_disk_size describe real behavior on the read side, not just the write side.
Sidekick registers as a Caddy admin module and exports Prometheus metrics: hit/miss/bypass rates, memory and disk usage against configured limits, item counts, response-time histograms by cache status, and cached-item size distributions.
# hit rate
sum(rate(caddy_sidekick_cache_operations_total{operation="get",status="hit"}[5m]))
/
sum(rate(caddy_sidekick_cache_operations_total{operation="get"}[5m])) * 100
You cannot operate a cache you cannot see. Every hardening item above started as somebody staring at a graph and asking why a number looked wrong.
Every WordPress site on our platform runs in a container built from a single image: FrankenPHP, Caddy, and Sidekick compiled into one binary via xcaddy. There is no separate cache tier to provision, scale, or page someone about at 2am.
The pieces that matter in our production config:
{
admin 0.0.0.0:2019
metrics /metrics
servers {
trusted_proxies static x.x.x.x/y 127.0.0.1/32 ::1/128
client_ip_headers X-Real-IP
trusted_proxies_strict
}
order php_server before file_server
order sidekick before rewrite
order request_header before sidekick
}
{$SERVER_NAME} {
root * /var/www/html/
encode br zstd gzip
sidekick {
cache_dir {$SIDEKICK_CACHE_DIR:/var/www/cache}
cache_ttl {$SIDEKICK_CACHE_TTL:300}
nocache {$SIDEKICK_NOCACHE:/wp-admin,/wp-json,/wp-cron.php,/wp-login.php,/xmlrpc.php}
cache_memory_max_size {$SIDEKICK_CACHE_MEMORY_MAX_SIZE:64MB}
cache_memory_max_count {$SIDEKICK_CACHE_MEMORY_MAX_COUNT:250}
cache_disk_max_size {$SIDEKICK_CACHE_DISK_MAX_SIZE:256MB}
cache_disk_max_count {$SIDEKICK_CACHE_DISK_MAX_COUNT:1000}
cache_key_cookies {$SIDEKICK_CACHE_KEY_COOKIES:wordpress_logged_in_*,wordpress_sec_*}
metrics /metrics/sidekick
}
php_server
file_server
}
A few notes on the choices there:
Every value is an environment variable with a default. One image, many tenants. A site on a small plan and a site on a large one run identical bytes with different limits, which means our cache configuration is a deployment concern rather than a build concern.
Memory limits are conservative. 64MB and 250 items per container. We run many containers per host, so per-container memory discipline is what keeps the host predictable. Anything larger than the threshold goes to disk and gets streamed from there.
/wp-cron.php and /xmlrpc.php are in the bypass list. Neither belongs in a page cache, and a cached wp-cron.php is a broken site with no scheduled tasks.
Metrics go to Prometheus on the admin port, scraped alongside every other Caddy metric. Cache hit rate is a first-class dashboard number for us, right next to CPU and memory.
trusted_proxies_strict is set, because Sidekick keys on the request host and we terminate at a load balancer. Client IP and host handling has to be exactly right or the cache key is built from an attacker-controllable header.
It’s MIT licensed. Build it with xcaddy, point it at a WordPress install, and check the X-Sidekick-Cache header. If you find a way to make it serve the wrong bytes to the wrong person, please open an issue — that’s the class of bug we care most about.
And if you’d rather not run any of this yourself, it’s already switched on for every site on HonestHosting.
We continue to work hard to bring you the best value for all of your WordPress hosting needs. If you have any questions, concerns, or would generally like to chat, feel free to contact us anytime. We want to make sure you get the most out of HonestHosting.io!