A WordPress plugin (WordPress 6.2+, PHP 7.4+) that serves external font dependencies from the
site itself. The browser no longer contacts
fonts.googleapis.com, fonts.gstatic.com or use.fontawesome.com. WordPress fetches
those files once, keeps them as content-addressed objects in the persistent object cache
(Redis) and serves them from /font-proxy/.... Nothing is written to disk.
<link href="/sitelet?url=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DMontserrat%3Aregular%26amp%3Bver%3D7.1">
↓
<link href="/sitelet?url=https%3A%2F%2Fexample.com%2Ffont-proxy%2F%26lt%3Bsig%26gt%3B%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DMontserrat%3Aregular">
- Copy the
font-proxy-cachefolder towp-content/plugins/. - Make sure a persistent object cache is enabled (see Requirements).
- Activate Font Proxy Cache under Plugins, or run
wp plugin activate font-proxy-cache.
No configuration is needed. Status and cache management are under Tools → Font Proxy Cache.
- A connected persistent object cache. In practice that means the Redis Object Cache plugin
with its
object-cache.phpdrop-in enabled (Settings → Redis → Enable Object Cache, orwp redis enable). Other persistent object cache drop-ins should also work, but only Redis Object Cache has been tested. - Activation is refused without it. If the object cache later becomes unavailable, the
plugin turns itself off and shows an admin notice. "Unavailable" covers three cases: the
drop-in is removed, Redis is unreachable, or the group is listed in
WP_REDIS_IGNORED_GROUPS. - If Redis goes down, the whole site is down, unless graceful mode is on. With
WP_REDIS_GRACEFULunset, the Redis Object Cache drop-in stops the whole site with "Error establishing a Redis connection" whenever Redis is unreachable, before any plugin loads. DefineWP_REDIS_GRACEFULastrueinwp-config.php(or viaWORDPRESS_CONFIG_EXTRAin the official WordPress Docker image) to keep the site running without Redis. This plugin then switches itself off. - While off:
- pages keep their original CDN URLs
/font-proxy/URLs still referenced by cached pages answer with a302to the CDN, withX-Font-Proxy-Cache-Error: font_proxy_cache_unavailable- nothing is cached anywhere
-
Rewriting page output
style_loader_srcrewrites enqueued stylesheets.- An output buffer rewrites hard-coded
<link>tags and<style>blocks, including@importand@font-face src:url(/sitelet?url=https%3A%2F%2Fgithub.com%2Fsolinter%2F...). Scripts and post content are left untouched. wp_preload_resourcesrewrites preloads.integrityattributes are removed from rewritten tags. The proxied CSS has its dependency URLs rewritten, so the upstream SRI hash would no longer match.dns-prefetchandpreconnecthints for the proxied hosts are removed, both WP-generated and hard-coded ones.
-
Proxy endpoint (
/font-proxy/<signature>/<host>/<path>?<query>). It runs onplugins_loaded, before the theme loads.- The signature is an HMAC of the canonical upstream URL. Only URLs generated by this site can be fetched, so the endpoint is not an open proxy.
- On a miss, the upstream file is fetched with a modern browser UA, so Google returns woff2
with
unicode-rangesubsets. Its content type is validated: CSS, woff2, woff, ttf, otf, eot and svg are accepted, and HTML error pages are rejected. - CSS is rewritten before caching. Every
url()and@importit contains is resolved against the upstream URL. That covers absolute gstatic URLs and Font Awesome's relative../webfonts/.... Resolved references are pointed at the proxy. - If upstream is down, a stale copy is served. If nothing is cached yet, the browser gets a 302 to the CDN, so the page keeps working.
-
Storage (object cache group
font_proxy_cache)Key Content <gen>:obj:<sha256>File body, named by the hash of its content <gen>:ref:<sha256(url)>One record per canonical URL, read on every proxy request <gen>:indexObjects (registered when written), refs and upstream failures, for the Tools page, garbage collection and purge. Updated under <gen>:lock<gen>is the purge generation. It is stored in the autoloadedfont_proxy_cache_generationoption, next to the signing secretfont_proxy_cache_secret.- Purge switches to a new generation, so every server stops using the old keys at once. The indexed old keys are deleted right away to free memory.
- Lock expiry is kept in the lock's value. With
WP_REDIS_MAXTTL=0, Redis Object Cache makes every TTL permanent. A lock left behind by a crashed request is taken over once its deadline passes. - Replaced objects are freed at once. When a ref stops pointing to an object, the object is deleted immediately. An object no ref points to yet, for example from an interrupted store, is deleted after 5 minutes. A purge deletes all of them.
- A hit costs two Redis GETs (ref and object).
- Evicted keys are fetched again automatically. If Redis evicts a ref or an object (e.g.
maxmemory-policy allkeys-lru), the file is fetched again on its next request. - Size: a typical setup (one Google Fonts family and Font Awesome 6) uses a few hundred KB to about 1.5 MB. Font Awesome's TTF fallbacks are cached only if a browser requests them.
The same file is never cached under two identifiers:
-
Canonical URL identity. Every spelling of a URL maps to one ref and one proxy URL:
//,http:andhttps:- host case and default ports
&and&- dot segments (
css/../webfonts) - percent-encoding (
%3Avs:,+vs%20) - query order
- the WordPress
verargument, which is dropped - fragments such as
?#iefix
So a font referenced from two stylesheets resolves to the same URL, and the browser caches it once too.
-
Content addressing. Different URLs that return identical bytes share a single object.
| Type | Upstream refresh | Browser Cache-Control |
|---|---|---|
| Fonts | never (versioned URLs) | public, max-age=31536000, immutable |
| CSS | weekly, conditional (ETag / Last-Modified) | public, max-age=86400, stale-while-revalidate=604800 |
The ETag is the object's content hash, and If-None-Match gets a 304 response. The
X-Font-Proxy-Cache header shows the cache status: HIT, MISS, REVALIDATED, REFRESHED,
REBUILT, STALE or BYPASS.
-
Tools → Font Proxy Cache shows the object-cache status, stats, cached URLs and upstream failures. It has Purge cache and Remove orphaned objects actions. It also shows when the page was rendered and by which server. If it shows an old time, it came from a cache in front of WordPress, not from the server.
-
WP-CLI:
wp font-proxy-cache status | list | purge | gc wp font-proxy-cache warm <url>... wp font-proxy-cache url <url>
When a file cannot be served locally, the proxy answers with a 302 to the original CDN. The
page keeps working, but that file and its dependencies are loaded from the CDN.
- Response headers. Look for
X-Font-Proxy-Cache: BYPASSandX-Font-Proxy-Cache-Error: <code> [<http status>]:font_proxy_cache_unavailable: no connected object cachefont_proxy_cache_http 403: upstream refused the requestfont_proxy_cache_storage: writing to Redis failedhttp_request_failed: network error
- Tools page. Tools → Font Proxy Cache lists the upstream failures with the full message. A
Cloudflare bot block is reported as
cf-mitigated: challenge. - Retries. Failed fetches are retried every 5 minutes. Purge cache retries on the next request.
Register these from a plugin or mu-plugin, not from the theme. The endpoint answers before the theme is loaded.
| Filter | Default |
|---|---|
font_proxy_cache_hosts |
fonts.googleapis.com, fonts.gstatic.com, use.fontawesome.com |
font_proxy_cache_ignored_query_args |
['ver'] |
font_proxy_cache_is_proxiable |
CSS/font extensions or extensionless paths |
font_proxy_cache_base_url |
home_url('/sitelet?url=https%3A%2F%2Fgithub.com%2Fsolinter%2Ffont-proxy%2F') (e.g. a CDN in front of the site) |
font_proxy_cache_ttl |
CSS 1 week, fonts 0 (never) |
font_proxy_cache_cache_control |
see table above |
font_proxy_cache_user_agent, font_proxy_cache_timeout |
modern Chrome UA, 10 s |
font_proxy_cache_enabled |
true (turns off rewriting only; the endpoint keeps serving) |
Define FONT_PROXY_CACHE_SECRET in wp-config.php to pin the signing secret. Otherwise a
secret is generated once and stored in the font_proxy_cache_secret option.
/font-proxy/... must reach WordPress's index.php. The standard WordPress .htaccess does
this on Apache. On nginx, make sure no location ~* \.(css|woff2)$ block answers these
requests with a 404 before try_files ... /index.php.