CDN setup
Put Cloudflare or another CDN in front of a self-hosted Keenpix instance.
Keenpix runs image processing on your server and emits immutable, CDN-ready image responses. Your CDN should cache only the image transform path:
/img/<SOURCE_URL>?project=PROJECT_ID&w=1200&fmt=webpDo not cache the dashboard, auth routes, docs, or health checks.
Cloudflare
-
Create a DNS record such as
images.example.compointing at your VPS and enable the orange-cloud proxy. -
Use Full (strict) SSL with a valid origin certificate or Let's Encrypt certificate on the VPS.
-
Create a Cache Rule matching only the image path:
http.host eq "keenpix.joodlab.com" and starts_with(http.request.uri.path, "/img/") -
Set the rule to Eligible for cache.
-
Leave Cache key unset on non-Enterprise plans. Cloudflare's default/standard cache key already includes the full request URI with its query string, so each
?w=/?fmt=variant is cached separately. Do not enable "Ignore query string". Enable query-string sorting if available. -
Let Cloudflare respect Keenpix's origin header, or set an edge TTL that matches the immutable image lifetime:
Cache-Control: public, max-age=31536000, immutable
Cloudflare proxying alone is not the complete setup for this app. The transform route is
dynamic from Cloudflare's perspective, so add the Cache Rule above to make /img/*
eligible for edge cache.
Measuring Cloudflare cache
Keenpix origin analytics only include requests that reach the Keenpix origin. A
Cloudflare edge HIT is served before the app runs, so it never appears in the
Keenpix request log or origin rollups. Treat the origin hit-rate as the
origin-shield hit-rate for Cloudflare misses, not the total end-user cache
hit-rate. To see the edge side, connect the built-in edge analytics below — Keenpix
pulls the Cloudflare numbers and persists them so they line up next to the
origin-shield figures.
For the total edge picture, use Cloudflare Cache Analytics filtered to the Keenpix
image hostname and /img/ path. Review cache status, top miss URLs, and data transfer
served by Cloudflare versus origin. This is the fastest way to validate the cache rule
and identify URLs that fragment the edge cache.
Built-in edge analytics
Keenpix can pull the real Cloudflare edge figures for you and show source-split
cards (Cloudflare edge vs Keenpix origin) and an hourly edge hit/miss chart on
Overview and /app/analytics, next to the origin-shield numbers. The cards cover
edge hit-rate, edge requests, and bytes served from the edge.
To connect it, go to Settings → CDN cache and add a Cloudflare API token scoped
to Analytics → Read, your 32-character hex Zone ID, and optionally a
hostname. The token is stored encrypted at rest — the key is derived from
BETTER_AUTH_SECRET, so the encrypted value is per-instance and cannot be copied
between instances; re-enter the token on each instance. Use Test connection to
validate the token and zone before saving. You can also configure these via the
CLOUDFLARE_*
environment variables;
database-managed settings (token + zone + enabled) take precedence over the env vars.
The optional hostname narrows the figures to one subdomain. A Cloudflare zone
covers the whole domain and the query only filters by path (/img/*), so if your
zone also serves other sites that use a /img path, set the hostname (e.g.
images.example.com) to attribute the numbers to Keenpix alone. Leave it blank to
count /img/* across the entire zone.
How the edge data is persisted
Keenpix reads the Cloudflare GraphQL httpRequestsAdaptiveGroups dataset — the only
one that supports the /img/* path filter with per-cache-status detail. On
non-Enterprise plans that dataset only answers for roughly the last 24 hours and
rejects wider queries. So instead of querying it live for each range, Keenpix
captures the last-24h hourly series and persists it to its own edge-rollup
table, then reconstructs edge analytics from that table for any range
(24h / 7d / 30d / 90d). The edge cards are no longer fixed to a 24h window.
Capture is opportunistic and throttled (about once every 15 minutes per zone/host). It runs as a best-effort background refresh when someone loads the Overview or analytics page — there is no cron or scheduler. The edge query also runs off the page's critical path, so a slow Cloudflare round-trip never blocks the page render. Each capture pulls a fresh last-24h series, so any gap shorter than 24h self-heals on the next dashboard visit. (If nobody opens the dashboard for more than 24h, that window is lost — fine for a regularly-used dashboard.)
History grows forward. Cloudflare won't backfill far beyond the recent window, so the first capture seeds about 24h of history. The 24h view reconciles right away; 7d and 30d fill in over the following days, and 90d accumulates over time. For a range that reaches before the captured history, the edge totals still show, but the source-split cards stay origin-only and add the note "Cloudflare edge history is still accumulating — older data for this range isn't available yet" rather than reconcile a misleading partial total.
The reconciled source-split appears only at All projects scope. Cloudflare has no
notion of the Keenpix project id, so edge data is zone/host-wide for /img/* —
per-project edge figures are not available.
For deeper, per-URL reporting, ingest Cloudflare HTTP request logs or analytics as a separate edge-cache data source. The useful fields are:
CacheCacheStatusforhit,miss,expired,revalidated,dynamic, and related statuses.ClientRequestHost,ClientRequestPath, andClientRequestURIto scope records to/img/*and parse the Keenpixprojectquery value.CacheResponseBytesfor bytes served from Cloudflare cache.CacheTieredFillandCacheReserveUsedif those Cloudflare products are enabled.
Do not try to derive edge-hit counts from /app/analytics; by design, those edge hits do
not contact the origin.
Format negotiation
The preferred URL shape puts the source URL in the path:
https://images.example.com/img/https://cdn.example.com/hero.jpg?project=store&w=1200&fmt=autoThat keeps the source file extension in Cloudflare's request path, which is important if
you use Cloudflare's image variant caching with Vary: Accept.
For the highest cache hit rate on any CDN, request explicit formats in the URL:
https://images.example.com/img/https://cdn.example.com/hero.jpg?project=store&w=1200&fmt=webpUse fmt=auto only when your CDN can cache separate variants by Accept or has image
variant support enabled. Otherwise, two browsers can ask for the same URL but need
different output formats. In Nuxt Image or JoodCMS-style integrations, leaving the
image component's format prop unset produces this auto-negotiated URL; setting
format="avif" or format="webp" forces a single variant.
On Cloudflare non-Enterprise plans, prefer explicit fmt values because custom cache
key headers, including Accept, are Enterprise-only.
SVG is fixed-format only. If you want optimized SVG bytes, request fmt=svg; fmt=auto
rasterizes SVG origins and varies the raster response by Accept.
Nginx
Nginx can be the caching layer in front of Keenpix. Cache only /img/, and put the
full request URI ($request_uri includes the path and query string) in the cache key
so every ?w= / ?fmt= variant is stored separately.
# Declare the cache store once, at the http{} level.
proxy_cache_path /var/cache/nginx/keenpix levels=1:2 keys_zone=keenpix:10m
max_size=10g inactive=30d use_temp_path=off;
server {
listen 443 ssl;
server_name images.example.com;
# ssl_certificate / ssl_certificate_key ...
# Cache the transform endpoint only.
location /img/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache keenpix;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 30d; # safety net; Keenpix already sends immutable
proxy_cache_use_stale error timeout updating;
add_header X-Cache-Status $upstream_cache_status;
}
# Dashboard, auth, docs, and /api/health stay uncached.
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Nginx honors the Cache-Control: … immutable and Vary: Accept headers Keenpix returns,
so fmt=auto is cached correctly per Accept value. Watch X-Cache-Status
(MISS then HIT) to confirm the cache is working.
Caddy
A plain Caddy reverse proxy forwards Keenpix's immutable headers untouched, so browsers
and any downstream CDN still cache /img/*:
images.example.com {
reverse_proxy 127.0.0.1:3000
}To make Caddy itself an edge cache, build it with the
cache-handler plugin
(xcaddy build --with github.com/caddyserver/cache-handler) and cache only the image
path:
{
cache # Souin-backed; respects upstream Cache-Control.
}
images.example.com {
@img path /img/*
route {
cache @img
reverse_proxy 127.0.0.1:3000
}
}Fastly, Bunny, CloudFront, and others
The same rules apply to any proxy or CDN:
- Cache only paths that start with
/img/. - Include the full path and full query string in the cache key.
- On Cloudflare non-Enterprise plans, leave the Cache key section unset; the default key already includes the query string.
- Respect
Cache-Controlfrom Keenpix — do not shorten or strip it. - Respect
Vary: Acceptif you usefmt=auto; otherwise generate explicitfmtvalues. - Keep dashboard, auth, docs, and
/api/healthuncached.
If the CDN cannot vary by Accept, use explicit fmt=avif, fmt=webp, fmt=jpeg,
fmt=png, or fmt=svg in generated image URLs.