Skip to content
Keenpix docs
Self-hosting

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=webp

Do not cache the dashboard, auth routes, docs, or health checks.

Cloudflare

  1. Create a DNS record such as images.example.com pointing at your VPS and enable the orange-cloud proxy.

  2. Use Full (strict) SSL with a valid origin certificate or Let's Encrypt certificate on the VPS.

  3. Create a Cache Rule matching only the image path:

    http.host eq "keenpix.joodlab.com" and starts_with(http.request.uri.path, "/img/")
  4. Set the rule to Eligible for cache.

  5. 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.

  6. 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:

  • CacheCacheStatus for hit, miss, expired, revalidated, dynamic, and related statuses.
  • ClientRequestHost, ClientRequestPath, and ClientRequestURI to scope records to /img/* and parse the Keenpix project query value.
  • CacheResponseBytes for bytes served from Cloudflare cache.
  • CacheTieredFill and CacheReserveUsed if 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=auto

That 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=webp

Use 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-Control from Keenpix — do not shorten or strip it.
  • Respect Vary: Accept if you use fmt=auto; otherwise generate explicit fmt values.
  • Keep dashboard, auth, docs, and /api/health uncached.

If the CDN cannot vary by Accept, use explicit fmt=avif, fmt=webp, fmt=jpeg, fmt=png, or fmt=svg in generated image URLs.

On this page