Page cache & purging

Every tenant site sits behind three page caches. A content change is only visible once the right ones are gone, and emptying them carelessly takes the site (and its neighbours) down for a while. SiteCacheManager clears them in a fixed order and re-warms the origin in between.

The three layers

  1. WP Fastest Cache: static HTML in wp-content/cache/all. nginx serves these files directly (try_files in the vhost), PHP never runs. A client with wp-admin access can disable or delete this layer.
  2. nginx fastcgi_cache: the shared WPCACHE zone in /var/cache/nginx/wpworks (provisioning/nginx/fastcgi-cache.conf), key $scheme$request_method$host$request_uri, 5 minutes for 200/301/302. This is the floor that survives anything done inside WordPress.
  3. Cloudflare edge: one zone for all *.wpworks.pl sites, Free plan, HTML cached for anonymous GETs by the cache rule CloudflareCacheManager installs (CLOUDFLARE_EDGE_CACHE_TTL, default 300 s). Sites on a customer domain point straight at the origin and have no edge layer.

Why order matters

Until October 2026 a purge deleted layers 1 and 2 and then called purge_everything on the Cloudflare zone. That emptied the edge for all 13 sites at once, so every site's next visitors hit a cold origin together. Each site's PHP-FPM pool has four ondemand children and a cold Divi render takes seconds, so the logs filled with server reached max_children setting (4) and nginx logged 499s: the "site is unreachable for a moment after a purge" the team reported. nginx's fastcgi_cache_use_stale cannot help here, because the stale files had just been deleted.

What a purge does now

SiteCacheManager::purge(Site, includeEdge, allowPurgeEverything), under a box-wide lock:

  1. Delete WPFC files: find <root>/wp-content/cache/all -mindepth 1 -delete as the site user.
  2. Delete this site's nginx entries: cache files store their key on a KEY: line in the header, so grep -rlaE '^KEY: https?(GET|HEAD)(host1|host2|…)/' over the zone directory lists exactly this site's files, which are then removed. Hosts are the site's fqdn, canonical_fqdn, every row in Site::domains, and the www/bare twin of each.
  3. Build the URL list (SiteUrlCollector): the sitemap is read from the origin (WP_HOSTER_CACHE_ORIGIN_URL, default https://127.0.0.1, with the site's Host header, TLS unverified, redirects not followed). It tries /sitemap_index.xml (Yoast, Rank Math), /sitemap.xml, then /wp-sitemap.xml (core); a sitemap index is expanded up to sitemap_max_children child sitemaps. Without a sitemap it falls back to /wp-json/wp/v2/pages and /posts. URLs with a query string or on a foreign hostname are dropped. The result is the home page first, then URLs by path depth (top-level pages before deep posts), capped at edge_max_urls (500).
  4. Warm the origin (SiteCacheWarmer): the home page plus the first warm_urls (20) URLs are requested one at a time against the origin with the site's Host header, no cookies, no query string, 20 s per URL, at most warm_budget_seconds (120) in total. Each request renders once through PHP and lands in both WPFC and fastcgi_cache under the same keys public traffic uses. The edge still serves its old copy meanwhile, so these are the only requests reaching the cold origin.
  5. Purge the edge by URL: the home page on every zone hostname of the site plus the URL list, in batches of 30 (the Free-plan limit per call), via CloudflareCacheManager::purgeUrls(). Hostnames outside WP_HOSTER_BASE_DOMAIN are left out, because one foreign URL makes Cloudflare reject the whole call. The log line SiteCacheManager: edge purged by URL carries the counts.

If no listing could be built at all (url_source = none), only the home pages are purged at the edge and a warning is logged; everything else expires with the edge TTL. Pass allowPurgeEverything = true (CLI: --purge-everything) to purge the whole zone in that case instead. wphoster:purge-cache without --site still purges the whole zone once at the end, on purpose: it is a box-wide operation.

Serialisation

The panel button, the WP admin-bar button (POST /api/agent/purge-cache) and the CLI all end in the same code, and the client button will be clicked repeatedly.

A purge can take a couple of minutes (sitemap, up to 21 renders, a few API calls). The job timeout is 600 s; the queue connection's retry_after must be higher than that or a second worker would start the same job again.

nginx: [crit] unlink() … No such file or directory

After a purge, nginx's error log shows one of these per deleted entry. They are harmless. nginx indexes cache entries in shared memory and only touches the file when the entry is requested or expired. We delete the file underneath it; a request in the meantime is simply a MISS, and when the cache manager later expires the entry (inactive=60m) its unlink() fails and is logged at crit, after which the entry is dropped from memory anyway. Nothing is leaked and nothing stale is served.

A "soft" invalidation that avoids the deletes (a per-host cache version in the key, loaded from a map file) was considered and rejected: nginx only re-reads such a file on nginx -s reload, so every purge would reload nginx for all tenants, and the old entries would still sit on disk until inactive evicts them. The open-source build has no purge directive (fastcgi_cache_purge is nginx Plus / a third-party module). Deleting the files is the least invasive option; the warming step is what makes it safe.

Key files

Config

Key (wphoster.cache.*)EnvDefaultPurpose
fastcgi_pathWP_HOSTER_FASTCGI_CACHE_PATH/var/cache/nginx/wpworksnginx cache zone directory
origin_urlWP_HOSTER_CACHE_ORIGIN_URLhttps://127.0.0.1Where the origin is reached directly (Host header selects the site)
warm_urlsWP_HOSTER_CACHE_WARM_URLS20Sitemap URLs warmed after the home page; 0 disables warming
warm_timeoutWP_HOSTER_CACHE_WARM_TIMEOUT20Seconds per warm request
warm_budget_secondsWP_HOSTER_CACHE_WARM_BUDGET120Total warming time before giving up on the rest
edge_batch_sizeWP_HOSTER_CACHE_EDGE_BATCH_SIZE30URLs per Cloudflare purge call (Free plan maximum)
edge_max_urlsWP_HOSTER_CACHE_EDGE_MAX_URLS500Cap on URLs purged per site
sitemap_max_childrenWP_HOSTER_CACHE_SITEMAP_MAX_CHILDREN10Child sitemaps read from a sitemap index
lock_wait_secondsWP_HOSTER_CACHE_LOCK_WAIT120How long a purge waits for the box-wide lock

Checking it worked

grep 'SiteCacheWarmer: origin warmed' storage/logs/laravel.log | tail -1
grep 'edge purged by URL' storage/logs/laravel.log | tail -1
curl -sI https://<site>/ | grep -i -e x-cache -e cf-cache-status   # X-Cache: HIT from nginx, cf-cache-status: MISS once, then HIT