# Keenpix documentation Complete hosted documentation for Keenpix, a self-hosted image optimization service. ## Analytics Source: https://keenpix.com/docs/concepts/analytics Analytics is built in, not bolted on. Every request to `/img/*` writes a log row (timestamp, project, source host, path, width, format, status, cache hit/miss, latency, bytes in/out) and increments an hourly Postgres rollup. Live Logs read the raw rows; dashboard analytics aggregate the rollups in Postgres, so even a 90-day range returns a handful of pre-summed rows and stays fast. The Overview and Analytics pages serve the last view instantly and revalidate in the background (stale-while-revalidate): changing the range or scope keeps the previous numbers on screen with a small *Refreshing…* indicator while fresh data loads, and a skeleton shows only on the very first load. Both default to a 24h range, so origin figures line up with the edge data described below. ## What you get [#what-you-get] * **KPIs** — total requests, bandwidth saved, cache hit-rate, and p95 latency, each with a real trend versus the previous window. * **Requests over time** — cache hits vs live-optimized, with a range toggle (24h–90d). * **Format distribution** — how much traffic each output format receives. * **Response latency** — a p50/p75/p90/p95/p99 breakdown and a histogram. * **Latency over time** — p50/p95/p99 per time bucket, so you can spot slow windows. * **Top images** — the heaviest paths, rankable by request count or delivered bytes. * **Traffic by country** — requests grouped by requester country (from the edge `CF-IPCountry` header). * **Requests by status** — 2xx / 3xx / 4xx / 5xx over time, so 304s read as cache, not errors. * **Bandwidth saved over time** — per-bucket and cumulative optimizer savings. * **Per-project / per-domain breakdown** — an org-wide project table at All-projects scope, or a per-source-domain table when one project is selected. * **Live logs** — every request, newest first, with faceted filters (format, status, cache, and source domain when a single project is selected). * **Optional Cloudflare edge analytics** — edge hit-rate, edge requests, bytes served from Cloudflare, and an hourly edge hit/miss split for `/img/*`, shown as source-split cards (Cloudflare edge vs Keenpix origin). Edge is persisted by Keenpix, so it reconciles across any range — 24h, 7d, 30d, 90d — not just a fixed 24h window. ## Scope: all projects or one [#scope-all-projects-or-one] The switcher at the top of the sidebar sets the scope. **All projects** shows org-wide totals plus a per-project breakdown; pick a single project to scope every chart, the logs, and the KPIs to it. Origin-shield analytics are aggregated from the hourly Postgres rollups, with raw logs kept for live/debug views. Edge hits are served before the origin and never reach the Keenpix server, so edge analytics are pulled separately from Cloudflare's adaptive Analytics API — and persisted by Keenpix so they survive Cloudflare's 24h window and build up history for wider ranges over time. ## Caching Source: https://keenpix.com/docs/concepts/caching Optimizing an image is expensive; serving it again should be free. Keenpix caches every transform on disk and is built to sit behind a CDN. ## The cache key [#the-cache-key] A result is keyed by everything that affects the output: ```text project + url + full transform option object ``` Two requests with identical effective parameters share one cached file. Requests that arrive while the first identical transform is still running share that same in-flight work too. If many identical requests arrive during a cold cache (e.g. a popular image after a deploy), Keenpix runs the fetch + encode **once** and shares the result, instead of doing the same work N times. ## CDN-friendly responses [#cdn-friendly-responses] Every successful response sets: ```http Cache-Control: public, max-age=31536000, immutable Vary: Accept ``` Because the source URL and every transform live in the URL, the response is immutable. Configure your CDN to cache `/img/*` responses, including the full query string. For Cloudflare, that usually means a Cache Rule for the endpoint; orange-cloud proxying alone is not enough for every dynamic-looking path. `Vary: Accept` keeps per-format variants correct under `fmt=auto` only when your CDN supports caching separate variants by `Accept`. On CDNs that do not, prefer explicit `fmt=avif`, `fmt=webp`, `fmt=jpeg`, `fmt=png`, or `fmt=svg` URLs. Explicit formats improve cache predictability, but they also disable browser-based AVIF/WebP negotiation for that URL. `fmt=svg` is one of those explicit formats; `fmt=auto` never preserves SVG output. ## Where it's stored [#where-its-stored] The disk cache lives at `KEENPIX_CACHE_DIR` (default `./.keenpix-cache`). Writes are atomic (temp file + rename) so a crash mid-write can't serve a truncated image. See [Configuration](/docs/self-hosting/configuration) for size limits and eviction. ## How it works Source: https://keenpix.com/docs/concepts/how-it-works Keenpix turns a URL into an optimized image. There's no build step and no SDK: the query string **is** the API. ## The request lifecycle [#the-request-lifecycle] ```text Browser │ GET /img/https://cdn.example.com/hero.jpg?project=ID&w=1200&fmt=auto ▼ Keenpix │ 1. Resolve the project, check the source host against the project allowlist (+ SSRF block) │ 2. Build a cache key from (project, url, full transform options) │ 3. Cache HIT? ──yes──▶ stream bytes from disk │ │ no │ ▼ │ 4. Fetch the original from your origin (size-capped, IP-pinned) │ 5. Transform with sharp: decode → geometry → effects → re-encode │ or optimize SVG directly when fmt=svg │ 6. Write the result to the disk cache, then stream it ▼ Response: image/avif|webp|jpeg (+ image/svg+xml only for fmt=svg) + Cache-Control: public, max-age=31536000, immutable ``` Every request is also written to a log that powers the [analytics](/docs/concepts/analytics). ## The sharp pipeline [#the-sharp-pipeline] Keenpix decodes the source once and runs a parameter-selected, safe pipeline: 1. **Decode + auto-orient** — honours EXIF rotation, with a pixel ceiling that guards against decompression bombs. 2. **Geometry** — optional crop, trim, rotate, flip/flop, resize, and extend. Resize uses the chosen `fit`, multiplied by `dpr`, and never upscales unless `enlarge=1`. With no `w`/`h`, the longest side is bounded so a huge original can't pin a CPU. 3. **Effects** — optional flatten, grayscale, tint, modulate, gamma, negate, normalize, threshold, median, blur, and sharpen. 4. **Re-encode** — to the negotiated `fmt` at the resolved quality. ## Format negotiation [#format-negotiation] When `fmt=auto` (or `fmt` is omitted) Keenpix reads the browser's `Accept` header and serves **AVIF → WebP → JPEG** in order of support, and sets `Vary: Accept`. Use `fmt=auto` behind a CDN only when that CDN can cache separate variants by `Accept`; otherwise generate explicit `fmt=avif|webp|jpeg|png` URLs. A project can turn auto-format off (then `fmt=auto` serves JPEG); an explicit format is always honoured. That means `fmt=avif` never falls back to WebP; it is a fixed-format request. SVG is not part of auto-negotiation. If the origin is SVG and the request uses `fmt=auto`, Keenpix rasterizes it and returns AVIF, WebP, or JPEG like any other raster request. Only explicit `fmt=svg` returns SVG bytes; it accepts SVG origins, optimizes them with SVGO, strips active content, and skips the raster Sharp pipeline. ## Per-project defaults [#per-project-defaults] Each project carries defaults applied when a request omits a parameter — **default quality**, **auto-format**, and **strip-metadata** — editable under **Settings → Pipeline**. See [Self-hosting → Configuration](/docs/self-hosting/configuration) for the instance-wide limits. ## Security Source: https://keenpix.com/docs/concepts/security A transform endpoint is, by definition, a "fetch any URL" engine — so it must be guarded. Keenpix treats that as core, not a fast-follow. ## The allowlist (access control) [#the-allowlist-access-control] Each project has a list of **allowed source hosts**. A request is served only if the source URL's host is on that project's list. The list is checked **before** the cache, and an empty list blocks everything (fail-closed). This is why a transform URL needs no API key: it is safe to publish, because it can only ever hit hosts you've approved. (Keenpix does have optional **internal API keys** for trusted backend systems that manage projects over the JSON API — those are separate from the public transform endpoint. See [Projects & access](/docs/getting-started/projects) and the [SDK API reference](/docs/reference/sdk-api).) ## SSRF protection [#ssrf-protection] Even for an allowed host, Keenpix refuses to become a proxy into your private network: * **Private/internal IPs are blocked** — loopback, RFC-1918, link-local, CGNAT, IPv6 unique-local / link-local / multicast, and IPv4-mapped IPv6 forms. * **The resolved IP is pinned** into the connection, closing the DNS-rebinding window between the check and the connect. * **Every redirect hop is re-validated**, so a `302` to an internal host is refused. ## Denial-of-service guards [#denial-of-service-guards] * Origin responses are **size-capped** (streamed with a byte ceiling → `413`). * Concurrent transforms are bounded; excess load sheds with `503` rather than OOM. * sharp runs with a `limitInputPixels` ceiling and `failOn: 'truncated'`. ## Sign-in [#sign-in] The dashboard is bootstrapped with a super admin account. Set `KEENPIX_SUPER_ADMIN_EMAIL` and `KEENPIX_SUPER_ADMIN_PASSWORD` before running the seed or Docker container. The super admin can create copyable invitation links for other staff from **Settings → Staff**. Email delivery is optional and uses SMTP configured from **Settings → Email** first. SMTP environment variables are only used as a fallback when Settings SMTP is disabled or incomplete. ## Astro Source: https://keenpix.com/docs/frameworks/astro Astro's built-in `` optimizes **local** assets at build time. To optimize **remote** images at request time, offload to Keenpix with the [universal helper](/docs/frameworks#the-universal-pattern) and a native `` (works in `.astro`, MDX, and any UI framework island): ```astro --- import { keenpix, keenpixSrcSet } from '../lib/keenpix' const src = 'https://cdn.example.com/hero.jpg' --- Hero ``` This skips Astro's own image service for that element, letting Keenpix (and your CDN) handle resizing and caching. ## Plain HTML & any backend Source: https://keenpix.com/docs/frameworks/html Because Keenpix is just a URL, it works with no framework at all — and with any server-side language (PHP, Ruby, Python, Go, …). Build the URL and render an ``. ## Static HTML [#static-html] ```html Hero ``` With no `fmt` parameter, Keenpix negotiates the output from the browser's `Accept` header. Add `fmt=avif`, `fmt=webp`, `fmt=jpeg`, `fmt=png`, or `fmt=svg` only when you want to force one format. `fmt=svg` is explicit SVG delivery. If the source is SVG and `fmt` is omitted or set to `auto`, Keenpix returns a negotiated raster format instead. ## Responsive with `srcset` [#responsive-with-srcset] ```html Hero ``` ## Server-side (example: PHP) [#server-side-example-php] ```php 'YOUR_ID', 'w' => $w, 'fmt' => 'auto']); return "https://keenpix.example.com/img/" . rawurlencode($src) . "?$q"; } ?> Hero ``` The same one-liner works in Rails, Django, Laravel, or a CMS template — just URL-encode source URLs that contain their own query string or fragment. ## Framework integrations Source: https://keenpix.com/docs/frameworks Keenpix has **no SDK**. Integrating it means producing the right URL and putting it in an `` (or your framework's image component). There are two flavours: 1. **Pluggable loaders** — frameworks with a built-in image component you can point at a custom backend: [Next.js](/docs/frameworks/nextjs) and [Nuxt](/docs/frameworks/nuxt). 2. **URL builder + native ``** — everywhere else. A tiny helper builds the URL and you render a normal image with `srcset` for responsiveness. ## The universal pattern [#the-universal-pattern] Drop this helper anywhere (it's framework-agnostic — plain TypeScript): ```ts // keenpix.ts const HOST = 'https://keenpix.example.com' const PROJECT = 'YOUR_PROJECT_ID' type Opts = { w?: number; h?: number; q?: number; fmt?: string; fit?: string; dpr?: number } export function keenpix(src: string, opts: Opts = {}): string { const params = new URLSearchParams({ project: PROJECT }) for (const [k, v] of Object.entries(opts)) { if (v != null) params.set(k, String(v)) } return `${HOST}/img/${encodeURIComponent(src)}?${params.toString()}` } // A responsive srcset across common widths. export function keenpixSrcSet(src: string, widths = [640, 960, 1280, 1920], opts: Opts = {}) { return widths.map((w) => `${keenpix(src, { ...opts, w })} ${w}w`).join(', ') } ``` Then render a fully responsive, auto-format image with native HTML: ```html Hero ``` Omitting `fmt` is the same as `fmt=auto`: Keenpix negotiates from the request's `Accept` header and returns AVIF, WebP, or JPEG. Add `fmt: 'webp'`, `fmt: 'avif'`, or `fmt: 'svg'` only when you want a fixed output format. SVG output is explicit; an SVG origin with `fmt=auto` is rasterized instead of returned as SVG. Tip: read `HOST` and `PROJECT` from environment variables so you can swap instances per environment without touching component code. ## Pick your framework [#pick-your-framework] Don't see yours? If it can render an `` tag, the [universal pattern](#the-universal-pattern) above already works — that includes Angular, Solid, Qwik, Gatsby, Laravel, Rails, Django, WordPress, and static-site generators. ## Next.js Source: https://keenpix.com/docs/frameworks/nextjs `next/image` lets you swap its optimizer for a custom loader. Point it at Keenpix and keep using `` exactly as before — Next still generates the `srcset`, Keenpix serves each width. ## 1. Add the loader [#1-add-the-loader] ```js // keenpix-loader.js export default function keenpixLoader({ src, width, quality }) { const host = process.env.NEXT_PUBLIC_KEENPIX_HOST const project = process.env.NEXT_PUBLIC_KEENPIX_PROJECT const origin = process.env.NEXT_PUBLIC_IMAGE_ORIGIN ?? '' // next/image passes `src` as-is; Keenpix needs an absolute URL. const url = src.startsWith('http') ? src : `${origin}${src}` const params = new URLSearchParams({ project, w: String(width), fmt: 'auto' }) if (quality) params.set('q', String(quality)) return `${host}/img/${encodeURIComponent(url)}?${params.toString()}` } ``` ## 2. Point next.config at it [#2-point-nextconfig-at-it] ```js // next.config.js module.exports = { images: { loader: 'custom', loaderFile: './keenpix-loader.js' }, } ``` ## 3. Use `` normally [#3-use-image-normally] ```jsx import Image from 'next/image' export default function Hero() { return Spring } ``` Add **both** your Keenpix host and your image origin host to the project's allowlist (**Settings → Security**). Set `NEXT_PUBLIC_KEENPIX_HOST`, `NEXT_PUBLIC_KEENPIX_PROJECT`, and `NEXT_PUBLIC_IMAGE_ORIGIN` in `.env`. ## Nuxt Source: https://keenpix.com/docs/frameworks/nuxt [`@nuxt/image`](https://image.nuxt.com) supports custom providers, so `` and `` can route through Keenpix with full responsive support. ## 1. Add a provider [#1-add-a-provider] ```ts // providers/keenpix.ts import { createOperationsGenerator } from '#image' const operationsGenerator = createOperationsGenerator({ keyMap: { width: 'w', height: 'h', quality: 'q', format: 'fmt', fit: 'fit' }, joinWith: '&', formatter: (key, value) => `${key}=${value}`, }) export const getImage = (src, { modifiers = {}, baseURL = '', project = '' } = {}) => { const operations = operationsGenerator(modifiers) const url = src.startsWith('http') ? src : `${baseURL}${src}` // Keenpix needs an absolute URL const query = `project=${project}${operations ? `&${operations}` : ''}` return { url: `${process.env.KEENPIX_HOST}/img/${encodeURIComponent(url)}?${query}`, } } ``` ## 2. Register it [#2-register-it] ```ts // nuxt.config.ts export default defineNuxtConfig({ modules: ['@nuxt/image'], image: { providers: { keenpix: { name: 'keenpix', provider: '~/providers/keenpix.ts', options: { baseURL: 'https://cdn.example.com', project: 'YOUR_ID' }, }, }, }, }) ``` ## 3. Use it [#3-use-it] ```vue ``` Leaving `format` unset means the generated Keenpix URL has no `fmt` parameter, so Keenpix uses the browser's `Accept` header and serves AVIF, WebP, or JPEG based on support, including for SVG sources. Passing `format="avif"` or `format="webp"` intentionally forces that output format; passing `format="svg"` is required when you want an SVG origin returned as optimized SVG. In `@nuxt/image`, the `format` prop maps to Keenpix's `fmt` query parameter. Do not pass a helper that always returns `avif` unless you want to force AVIF for every client. For JoodCMS-style wrappers, keep the wrapper's default `format` value undefined and only pass a format for deliberate fixed-format URLs. If you don't need `@nuxt/image`'s responsive machinery, skip the provider and bind a plain `` using the [universal helper](/docs/frameworks#the-universal-pattern). ## React (Vite) Source: https://keenpix.com/docs/frameworks/react For any React app (Vite, CRA, Parcel…), wrap the [universal helper](/docs/frameworks#the-universal-pattern) in a reusable component: ```tsx // KeenpixImage.tsx import { keenpix, keenpixSrcSet } from './keenpix' type Props = React.ComponentProps<'img'> & { src: string; width?: number } export function KeenpixImage({ src, width = 1280, sizes = '100vw', ...rest }: Props) { return ( ) } ``` ```tsx ``` This works identically in Solid, Qwik, and Preact — only the `class`/`className` prop name differs. ## Remix / React Router Source: https://keenpix.com/docs/frameworks/remix Remix (and React Router v7) has no image component, so use the [universal helper](/docs/frameworks#the-universal-pattern) with a native `` — or the same `` from the [React guide](/docs/frameworks/react). ```tsx import { keenpix, keenpixSrcSet } from '~/lib/keenpix' export default function Hero() { const src = 'https://cdn.example.com/hero.jpg' return ( Hero ) } ``` Expose the host/project to the client via Remix's `window.ENV` pattern (a `loader` + a ` ``` ```svelte ``` Put `PUBLIC_KEENPIX_HOST` / `PUBLIC_KEENPIX_PROJECT` in `.env` and read them via `$env/static/public` inside `keenpix.ts`. ## TanStack Start Source: https://keenpix.com/docs/frameworks/tanstack-start TanStack Start (and TanStack Router) has no built-in image component, so use the [universal helper](/docs/frameworks#the-universal-pattern) with a native ``. This is also exactly how the Keenpix dashboard itself is built. ```tsx import { keenpix, keenpixSrcSet } from '~/lib/keenpix' export function Hero() { return ( Hero ) } ``` Read `HOST` / `PROJECT` from `import.meta.env` (e.g. `VITE_KEENPIX_HOST`) so the helper works in both server and client renders. ## Vue Source: https://keenpix.com/docs/frameworks/vue In any Vue 3 app, import the [universal helper](/docs/frameworks#the-universal-pattern) and bind it. For reuse, make a tiny component: ```vue ``` ```vue ``` (Prefer `@nuxt/image`? See the [Nuxt guide](/docs/frameworks/nuxt) for a custom provider.) ## Projects & access Source: https://keenpix.com/docs/getting-started/projects A **project** is Keenpix's core primitive: it's one origin, one allowlist, and one analytics view. A single Keenpix instance can host many projects (one per site, brand, or environment). ## Keyless transforms by design [#keyless-transforms-by-design] The transform endpoint needs no API key. Access is gated entirely by each project's **domain allowlist** — Keenpix will only fetch an image whose source host is on the list. This means: * You can put the transform URL directly in public HTML; there's no secret to leak. * An empty allowlist blocks **every** request (fail-closed). * Private/internal addresses are always refused (see [Security](/docs/concepts/security)). For trusted backend systems that manage projects programmatically, Keenpix also offers **internal API keys** over the JSON API. Those are separate from the public transform endpoint, which stays keyless. See the [SDK API reference](/docs/reference/sdk-api) for project management, allowed-host updates, configuration discovery, and cache prewarming. ## Anatomy of a request [#anatomy-of-a-request] ```text https://keenpix.example.com/img/https://cdn.example.com/hero.jpg ?project=PROJECT_ID ← which project (its allowlist gates access) &w=1200&fmt=auto ← transforms ``` ## Scoping the dashboard [#scoping-the-dashboard] The dashboard works at two levels: **All projects** (org-wide analytics and logs) or a single project picked from the switcher at the top of the sidebar. Settings are always per-project. ## Quickstart Source: https://keenpix.com/docs/getting-started/quickstart Keenpix is just an HTTP endpoint, so the fastest integration is a plain `` tag. ## 1. Allow your source host [#1-allow-your-source-host] Open **Settings → Security → Allowed hosts** for your project and add the hostname your images come from (e.g. `cdn.example.com`). Keenpix refuses to fetch from any host that isn't on the list — an empty allowlist blocks every request. This is the entire access model for transform URLs: no API key required. It's under **Settings → General → Project ID**. You pass it as `project=` in every URL. ## 2. Point an `` at the endpoint [#2-point-an-img-at-the-endpoint] ```html Hero ``` The first request is a cache **MISS** (Keenpix fetches + transforms the original); every later request for the same parameters is a **HIT** served straight from disk. ## 3. Tune the output [#3-tune-the-output] Add query parameters to resize and re-encode: | Param | Example | Effect | | ----- | ---------- | ----------------------------------------------------------------------------------------- | | `w` | `w=1200` | Resize to 1200px wide | | `q` | `q=80` | Quality 30–100 (default from settings) | | `fmt` | `fmt=auto` | Negotiate AVIF/WebP/JPEG from `Accept`; use `fmt=svg` explicitly for optimized SVG output | | `dpr` | `dpr=2` | Render at 2× for retina screens | See the [API reference](/docs/reference/parameters) for the full list, or jump to your [framework guide](/docs/frameworks). ## Documentation Source: https://keenpix.com/docs Keenpix is a self-hosted image-optimization service — an open-source alternative to ImageKit, imgix, and Cloudinary. Its public transform API is a single HTTP endpoint that fetches an image from your origin, transforms it with [sharp](https://sharp.pixelplumbing.com) (resize, crop, filter, and re-encode), optimizes SVG explicitly with `fmt=svg`, caches the result on disk, and serves it behind a long-lived, immutable `Cache-Control`. There's no SDK to install and no API key in your image URLs — **a request is just a URL**: ```html Hero ``` ## Start here [#start-here] * [**Quickstart**](/docs/getting-started/quickstart) — your first optimized image in two minutes. * [**How it works**](/docs/concepts/how-it-works) — the request lifecycle, caching, and security model. * [**Frameworks**](/docs/frameworks) — drop Keenpix into Next.js, Nuxt, TanStack Start, SvelteKit, Astro, and more. * [**API reference**](/docs/reference/endpoint) — transform parameters, response headers, and the authenticated SDK API. * [**Self-hosting**](/docs/self-hosting) — deploy with Docker in about a minute. ## Why Keenpix [#why-keenpix] * **Drop-in.** Any framework that renders an `` works — no rewrite required. * **Self-hosted.** Runs as a single container on your own infrastructure; put a configured CDN in front of it. * **Keyless.** Access is gated by a per-project domain allowlist, not by secrets. * **Built-in analytics.** Every origin request is logged, and optional Cloudflare edge analytics show the cache layer in front. ## Transform endpoint Source: https://keenpix.com/docs/reference/endpoint Keenpix exposes one public image endpoint. No API key is used for image URLs - the project's allowlist is the access control. ```http GET /img/SOURCE_URL?project=PROJECT_ID&w=…&fmt=… GET /img/?url=SOURCE_URL&project=PROJECT_ID&w=…&fmt=… ``` | Part | Required | Notes | | -------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | path source or `url` | yes | Absolute source URL after `/img/`, or in the `url` query parameter when no path source is present. Host must be allowlisted. Missing -> `400`. | | `project` | yes | Project id; its allowlist gates access. Missing → `400`. | | transforms | no | `w`, `h`, `q`, `fmt`, `fit`, `dpr`, `blur`, Sharp/IPX-style modifiers — see [Parameters](/docs/reference/parameters). | Simple source URLs can be written directly in the path. If the source URL contains its own `?` or `#`, URL-encode the source before appending Keenpix transform parameters. The `url` query parameter is also accepted for backends or integrations that prefer to keep the source URL out of the path. ## Example [#example] ```bash curl -I "https://keenpix.example.com/img/https://cdn.example.com/hero.jpg?project=store&w=800&fmt=webp" # HTTP/1.1 200 OK # content-type: image/webp # cache-control: public, max-age=31536000, immutable # vary: Accept curl -I "https://keenpix.example.com/img/?url=https%3A%2F%2Fcdn.example.com%2Fhero.jpg&project=store&w=800&fmt=webp" ``` Next steps: [Parameters](/docs/reference/parameters) · [Responses & errors](/docs/reference/responses) · [SDK API](/docs/reference/sdk-api). ## Operations Source: https://keenpix.com/docs/reference/operations These endpoints are for operators and deployment platforms. They are separate from the public image transform endpoint and the authenticated SDK API. ## Health [#health] ```http GET /api/health ``` The health endpoint is unauthenticated so container platforms, reverse proxies, and uptime monitors can call it. It returns JSON with `Cache-Control: no-store`. | Status | Meaning | | ------ | ------------------------------------------------------------- | | `200` | The app can reach the database and runtime checks completed. | | `503` | The database health check failed or the instance is degraded. | Example response: ```json { "ok": true, "service": "keenpix", "status": "ok", "timestamp": "2026-06-12T02:30:00.000Z", "uptimeSeconds": 123, "checks": { "cache": { "diskSizeBytes": 1048576, "diskMaxBytes": 2147483648, "memorySizeBytes": 262144, "memoryMaxBytes": 67108864 }, "database": { "ok": true, "latencyMs": 2 }, "transformQueue": { "active": 0, "queued": 0, "concurrency": 4, "maxQueue": 100, "rejected": 0, "status": "idle" } }, "latencyMs": 3 } ``` The exact cache and queue fields may grow as the operations dashboard grows, so treat `ok`, `status`, `checks.database.ok`, and the HTTP status as the stable monitoring contract. ## Parameters Source: https://keenpix.com/docs/reference/parameters All transform parameters are optional; out-of-range values are clamped, not rejected. | Param | Type | Range / values | Default | Description | | ------------------------------ | ---------- | ------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------- | | `project` | string | — | **required** | Project id (allowlist gates access) | | `url` | URL | absolute `http`/`https` URL | — | Source image URL when not using the `/img/` path form | | `w` / `h` | int | 1–5000 | bounded original | Target width/height (px) | | `resize` / `s` | string | `WIDTHxHEIGHT`, `WIDTH`, `xHEIGHT` | — | Compact size alias; explicit `w`/`h` win | | `q` | int | 30–100 | project default | Output quality | | `fmt` | enum | `auto` `avif` `webp` `jpeg` `png` `gif` `heif` `tiff` `svg` | `auto` | Output format (`auto` negotiates AVIF/WebP/JPEG) | | `fit` | enum | `cover` `contain` `fill` `inside` `outside` | `cover` | How to fit the `w`×`h` box | | `position` / `pos` / `gravity` | enum | edges, corners, compass gravities, `entropy`, `attention` | `centre` | Crop anchor for `cover`/`contain` | | `dpr` | int | 1–3 | `1` | Device pixel ratio (multiplies `w`/`h`) | | `enlarge` | bool | `1` `true` `yes` `on` | `false` | Allow upscaling past source dimensions | | `kernel` | enum | `nearest` `linear` `cubic` `mitchell` `lanczos2` `lanczos3` `mks2013` `mks2021` | `lanczos3` | Resize kernel | | `background` / `bg` | color | Sharp color string | — | Fill color for `contain`, `flatten`, `extend`, and arbitrary rotate | | `flatten` | bool | `1` `true` `yes` `on` | `false` | Merge alpha onto `background` | | `extract` / `crop` | string | `left,top,width,height` | — | Crop an exact source rectangle before resize | | `trim` | bool/int | `true` or 0–255 | `false` | Trim matching edges; number is threshold | | `extend` | string | `all`, `y,x`, `top,right,bottom,left` | — | Add output padding after resize | | `extendWith` | enum | `background` `copy` `repeat` `mirror` | `background` | Fill mode for `extend` | | `rotate` / `r` | float | -360–360 | — | Rotate by degrees after EXIF auto-orient | | `flip` / `flop` | bool | `1` `true` `yes` `on` | `false` | Vertical / horizontal mirror | | `blur` | int | 0–1000 | `0` | Gaussian blur sigma | | `sharpen` | bool/float | `true` or 0.000001–10 | `false` | Apply mild or sigma-based sharpening | | `median` | int | 1–25 | — | Median filter size | | `gamma` / `gammaOut` | float | 1–3 | — | Gamma correction | | `negate` | bool | `1` `true` `yes` `on` | `false` | Invert colors | | `normalize` / `normalise` | bool | `1` `true` `yes` `on` | `false` | Contrast normalization | | `threshold` | int | 0–255 | — | Convert to black/white threshold | | `brightness` / `saturation` | float | 0–10 | — | Modulate brightness/saturation | | `hue` | int | -360–360 | — | Hue rotation | | `lightness` | float | -100–100 | — | Lightness adjustment | | `tint` | color | Sharp color string | — | Apply a tint | | `grayscale` / `greyscale` | bool | `1` `true` `yes` `on` | `false` | Convert to grayscale | | `animated` / `a` | bool | `1` `true` `yes` `on` | `false` | Preserve animated GIF/WebP frames when output supports it | ## Notes [#notes] * **No upscaling by default.** Keenpix never enlarges past the source resolution unless `enlarge=1` is set. * **Source URL.** Put the absolute source URL after `/img/`, for example `/img/https://cdn.example.com/hero.jpg?project=ID&w=1200`, or send it as `/img/?url=https%3A%2F%2Fcdn.example.com%2Fhero.jpg&project=ID&w=1200`. * **`dpr`** multiplies the target dimensions — `w=400&dpr=2` renders at 800px. * **`fmt=auto`** depends on the project's *Auto-format* setting; when off, `auto` serves JPEG. Omitting `fmt` is treated as `auto`. It is raster negotiation only: SVG origins are rasterized and returned as AVIF, WebP, or JPEG rather than preserved as SVG. An explicit `fmt` is always honoured and disables browser negotiation for that request. Use explicit `fmt` values when your CDN cannot cache separate variants by `Accept`. * **Framework format props.** Image frameworks often map `format="avif"` or `format="webp"` directly to `fmt=avif` or `fmt=webp`. Leave those props unset when you want Keenpix to return AVIF or WebP based on browser support. * **`fmt=svg`** is explicit only. It returns `image/svg+xml` only for SVG origins, optimizes them through SVGO, and strips active content. Raster transform modifiers do not apply to SVG output. * **`q`** defaults to the project's *Default quality* (**Settings → Pipeline**). ## Responses & errors Source: https://keenpix.com/docs/reference/responses ## Success headers [#success-headers] | Header | Value | | --------------- | -------------------------------------------------------------------------- | | `Content-Type` | `image/avif` · `image/webp` · `image/jpeg` · `image/png` · `image/svg+xml` | | `Cache-Control` | `public, max-age=31536000, immutable` | | `Vary` | `Accept` (for CDNs that cache per-format under `fmt=auto`) | ## Status codes [#status-codes] | Status | Meaning | | ------ | ------------------------------------------------------------------------- | | `200` | Optimized image returned | | `400` | Missing `project` or source URL | | `403` | Source host is not on the allowlist, or resolves to a private/SSRF target | | `404` | Unknown `project` | | `413` | Origin image exceeds the size cap (`KEENPIX_MAX_ORIGIN_BYTES`) | | `502` | Origin fetch failed | | `503` | Transform concurrency limit reached — retry shortly | | `504` | Origin request timed out (`KEENPIX_ORIGIN_TIMEOUT_MS`) | A `403` almost always means the source host isn't on the project's allowlist. Add it under **Settings → Security → Allowed hosts**. ## SDK API Source: https://keenpix.com/docs/reference/sdk-api The SDK API is for trusted backend systems and external integrations that need to manage Keenpix projects programmatically. It is separate from the public `/img/*` transform endpoint: image URLs stay keyless and are still protected by each project's allowed source hosts. Create an internal API key from **Settings -> API keys** as a super admin. Keys can be scoped to every project or to one project. Project-scoped keys can read and write only that project and cannot create new projects. Send the key with either header: ```http Authorization: Bearer X-Keenpix-Api-Key: ``` All SDK responses are JSON and set `Cache-Control: no-store`. SDK requests are recorded under **Settings -> API keys -> API activity**. ## Endpoints [#endpoints] | Method | Path | Scope | Purpose | | -------- | --------------------------------------------------- | ----------------- | -------------------------------------------------------- | | `GET` | `/api/sdk/projects` | read | List projects visible to the key. | | `POST` | `/api/sdk/projects` | all-project write | Create a project. | | `GET` | `/api/sdk/projects/` | read | Fetch one project. | | `GET` | `/api/sdk/projects//configuration` | read | Fetch integration configuration for building image URLs. | | `PATCH` | `/api/sdk/projects//settings` | write | Update transform defaults. | | `POST` | `/api/sdk/projects//prewarm` | write | Queue cache prewarming for source images. | | `POST` | `/api/sdk/projects//domains` | write | Add an allowed source host. | | `DELETE` | `/api/sdk/projects//domains?host=` | write | Remove an allowed source host. | ## Projects [#projects] Create a project with an all-project write key: ```bash curl -X POST "https://keenpix.example.com/api/sdk/projects" \ -H "Authorization: Bearer $KEENPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Storefront", "origin": "https://cdn.example.com", "env": "production", "allowedOrigins": ["cdn.example.com"] }' ``` Request body: | Field | Type | Required | Notes | | ---------------- | --------- | -------- | ----------------------------------------------------- | | `name` | string | yes | 1-80 characters. | | `origin` | URL | yes | `http` or `https` origin URL. | | `env` | string | yes | `production`, `staging`, or `development`. | | `allowedOrigins` | string\[] | no | Hostnames to allow. URLs are normalized to hostnames. | List or fetch projects: ```bash curl "https://keenpix.example.com/api/sdk/projects" \ -H "Authorization: Bearer $KEENPIX_API_KEY" curl "https://keenpix.example.com/api/sdk/projects/$PROJECT_ID" \ -H "Authorization: Bearer $KEENPIX_API_KEY" ``` Project responses include the project id, origin, environment, allowed origins, color fields, transform defaults, and `createdAt`. ## Configuration [#configuration] Use the configuration endpoint when an external CMS or backend needs the canonical image base URL and the supported transform parameters. ```bash curl "https://keenpix.example.com/api/sdk/projects/$PROJECT_ID/configuration" \ -H "Authorization: Bearer $KEENPIX_API_KEY" ``` ```json { "configuration": { "projectId": "cm...", "projectName": "Storefront", "origin": "https://cdn.example.com", "allowedOrigins": ["cdn.example.com"], "imageBaseUrl": "https://keenpix.example.com/img", "transformUrlTemplate": "https://keenpix.example.com/img/?project=cm...", "defaults": { "autoFormat": true, "defaultQuality": 75, "stripMetadata": true }, "supportedParameters": [ "project", "url", "w", "h", "q", "fmt", "fit", "dpr", "blur", "position", "pos", "gravity", "background", "bg", "flatten", "resize", "s", "enlarge", "kernel", "extract", "crop", "trim", "extend", "extendWith", "rotate", "r", "flip", "flop", "sharpen", "median", "gamma", "gammaOut", "negate", "normalize", "normalise", "threshold", "brightness", "saturation", "hue", "lightness", "tint", "grayscale", "greyscale", "animated", "a" ] } } ``` `imageBaseUrl` and `transformUrlTemplate` are derived from proxy headers (`X-Forwarded-Host` and `X-Forwarded-Proto`) when present, then fall back to the request origin. Configure your reverse proxy to forward the public host and protocol so third-party integrations receive the external Keenpix URL. ## Settings [#settings] Patch one or more transform defaults: ```bash curl -X PATCH "https://keenpix.example.com/api/sdk/projects/$PROJECT_ID/settings" \ -H "Authorization: Bearer $KEENPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "autoFormat": true, "defaultQuality": 82, "stripMetadata": true }' ``` | Field | Type | Notes | | ---------------- | ------- | ----------------------------------------------- | | `autoFormat` | boolean | Enables `fmt=auto` negotiation. | | `defaultQuality` | integer | `30` through `100`. | | `stripMetadata` | boolean | Removes image metadata from transformed output. | Send at least one field. ## Domains [#domains] Allowed source hosts control what `/img/*` may fetch. Add a host with JSON: ```bash curl -X POST "https://keenpix.example.com/api/sdk/projects/$PROJECT_ID/domains" \ -H "Authorization: Bearer $KEENPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "host": "assets.example.com" }' ``` Remove a host with a query string or JSON body: ```bash curl -X DELETE "https://keenpix.example.com/api/sdk/projects/$PROJECT_ID/domains?host=assets.example.com" \ -H "Authorization: Bearer $KEENPIX_API_KEY" ``` Hosts are normalized to lowercase hostnames. Schemes, paths, and ports are removed when possible. ## Prewarm [#prewarm] Use prewarm after a trusted integration uploads or publishes images. Keenpix queues the requested transform variants and returns immediately with `202`; it does not wait for every transform to finish and it does not create user-facing request-log rows. Source URLs still have to pass the project's allowed-host and SSRF checks when the queued work runs. ```bash curl -X POST "https://keenpix.example.com/api/sdk/projects/$PROJECT_ID/prewarm" \ -H "Authorization: Bearer $KEENPIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sources": ["https://cdn.example.com/uploads/photo.jpg"], "widths": [320, 640, 768, 960, 1280], "formats": ["avif", "webp"], "quality": 75, "fit": "cover", "dpr": 1 }' ``` Request body: | Field | Type | Default | Notes | | --------- | ---------- | ---------------------------- | ----------------------------------------- | | `src` | URL | - | One source URL. Use `src` or `sources`. | | `sources` | URL\[] | - | 1-20 source URLs. Use `src` or `sources`. | | `widths` | integer\[] | `[320, 640, 768, 960, 1280]` | 1-10 widths, each 1-5000. | | `formats` | string\[] | `["avif", "webp"]` | `auto`, `avif`, `webp`, `jpeg`, or `png`. | | `quality` | integer | project default | `30` through `100`. | | `fit` | string | `cover` | `cover`, `contain`, `fill`, or `inside`. | | `dpr` | integer | `1` | `1` through `3`. | A single prewarm request can queue at most 200 variants: `unique sources * widths * formats <= 200`. Successful response: ```json { "prewarm": { "accepted": true, "sourceCount": 1, "variantCount": 10 } } ``` ## Errors [#errors] SDK errors use this shape: ```json { "error": "Missing API key" } ``` | Status | When | | ------ | ---------------------------------------------------------------------------------- | | `400` | Invalid JSON, invalid URL or host, invalid settings, or too many prewarm variants. | | `401` | Missing or invalid API key. | | `403` | Project-scoped key cannot access the requested project or operation. | | `404` | Unknown project or endpoint. | | `429` | API key rate limit exceeded. | | `500` | Unexpected SDK API failure. | ## CDN setup Source: https://keenpix.com/docs/self-hosting/cdn Keenpix runs image processing on your server and emits immutable, CDN-ready image responses. Your CDN should cache only the image transform path: ```text /img/?project=PROJECT_ID&w=1200&fmt=webp ``` Do not cache the dashboard, auth routes, docs, or health checks. ## Cloudflare [#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: ```text 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: ```http 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 [#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 [#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](/docs/self-hosting/configuration#cloudflare-edge-analytics); 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 [#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 [#format-negotiation] The preferred URL shape puts the source URL in the path: ```text 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: ```text 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] 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. ```nginx # 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 [#caddy] A plain Caddy reverse proxy forwards Keenpix's immutable headers untouched, so browsers and any downstream CDN still cache `/img/*`: ```text images.example.com { reverse_proxy 127.0.0.1:3000 } ``` To make Caddy itself an edge cache, build it with the [cache-handler](https://github.com/caddyserver/cache-handler) plugin (`xcaddy build --with github.com/caddyserver/cache-handler`) and cache only the image path: ```text { 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 [#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. ## Configuration Source: https://keenpix.com/docs/self-hosting/configuration Set these in `.env` (or your orchestrator's secrets). ## Core [#core] | Variable | Required | Default | Purpose | | ------------------------------ | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------ | | `DATABASE_URL` | yes | — | Postgres connection string | | `POSTGRES_PASSWORD` | yes (compose) | — | Password for the bundled Compose Postgres service | | `BETTER_AUTH_SECRET` | yes (prod) | — | Session secret; must be 32+ random chars | | `BETTER_AUTH_URL` | no | `http://localhost:3000` | Public base URL for auth callbacks and secure-cookie behavior | | `KEENPIX_APP_URL` | no | `BETTER_AUTH_URL` | Canonical app URL for generated metadata, docs links, and OG URLs | | `KEENPIX_SUPER_ADMIN_EMAIL` | yes | — | Email for the bootstrapped super admin account | | `KEENPIX_SUPER_ADMIN_PASSWORD` | yes | — | Password for the bootstrapped super admin account | | `KEENPIX_ADMIN_EMAIL` | legacy alias | — | Backward-compatible alias for `KEENPIX_SUPER_ADMIN_EMAIL` | | `KEENPIX_ADMIN_PASSWORD` | legacy alias | — | Backward-compatible alias for `KEENPIX_SUPER_ADMIN_PASSWORD` | | `KEENPIX_RUN_MIGRATIONS` | no | `true` in Docker | Docker entrypoint switch for running migrations before start | | `KEENPIX_RUN_SEED` | no | `true` in Docker | Docker entrypoint switch for seeding the default org and super admin before start | | `KEENPIX_CACHE_DIR` | no | `./.keenpix-cache` | Where transformed images are cached on disk | | `KEENPIX_MODE` | no | `selfhost` | `selfhost` (default) or `cloud`. Self-host disables the marketing site, self-signup, and billing | | `LOG_LEVEL` | no | `info` | Server log level: `fatal`, `error`, `warn`, `info`, `debug`, `trace`, or `silent` | ## Browser public URLs [#browser-public-urls] These Vite variables are only needed when the browser bundle must call a non-default public app or auth URL. | Variable | Default | Purpose | | ------------------------- | ------- | ----------------------------------------- | | `VITE_KEENPIX_PUBLIC_URL` | — | Browser-facing app URL | | `VITE_KEENPIX_AUTH_URL` | — | Backward-compatible auth URL alias | | `VITE_BETTER_AUTH_URL` | — | Backward-compatible Better Auth URL alias | ## Transactional email [#transactional-email] Verification, password reset, and teammate invitation emails go through exactly **one** provider, selected with `EMAIL_PROVIDER`. Email is configured entirely from the environment — there is no in-app email settings screen. Leave `EMAIL_PROVIDER` unset to disable email (fine for a single-admin install that never invites teammates). When it is set, the selected provider's required variables must be present or the app fails to start. | Variable | Default | Purpose | | ---------------- | ------- | --------------------------------------------- | | `EMAIL_PROVIDER` | — | `postmark`, `resend`, or `smtp` (unset = off) | ### `EMAIL_PROVIDER=postmark` [#email_providerpostmark] | Variable | Default | Purpose | | ------------------------- | ---------- | ------------------------------------------------------- | | `POSTMARK_API_KEY` | — | Postmark server token (required) | | `POSTMARK_FROM` | — | Verified sender, e.g. `Keenpix ` (required) | | `POSTMARK_MESSAGE_STREAM` | `outbound` | Message stream to send on | ### `EMAIL_PROVIDER=resend` [#email_providerresend] | Variable | Default | Purpose | | ---------------- | ------- | ------------------------------------------------------- | | `RESEND_API_KEY` | — | Resend API key (required) | | `RESEND_FROM` | — | Verified sender, e.g. `Keenpix ` (required) | ### `EMAIL_PROVIDER=smtp` [#email_providersmtp] Point at any SMTP relay (a provider, or a self-hosted Postfix/Mailpit). | Variable | Default | Purpose | | ----------------- | ------- | --------------------------------------------- | | `SMTP_HOST` | — | SMTP server hostname (required) | | `SMTP_FROM_EMAIL` | — | Sender email address (required) | | `SMTP_PORT` | `587` | SMTP server port | | `SMTP_SECURE` | `false` | `true` for implicit TLS, `false` for STARTTLS | | `SMTP_USER` | — | SMTP username | | `SMTP_PASSWORD` | — | SMTP password | | `SMTP_FROM_NAME` | — | Sender display name | ## Cloudflare edge analytics [#cloudflare-edge-analytics] Optional. Wire a Cloudflare API token so keenpix can show real edge cache hit-rate alongside its origin-shield figures (see [edge analytics](/docs/self-hosting/cdn#built-in-edge-analytics)). keenpix now persists hourly edge rollups, so edge analytics covers any range and accumulates history beyond Cloudflare's \~24h adaptive window — coverage grows forward as the dashboard is used. Manage it from **Settings → CDN cache**; when database-managed settings are enabled with a token and zone, they take precedence over the environment variables below. The token only needs the zone-scoped **Analytics → Read** permission. It is stored encrypted at rest with a key 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. | Variable | Default | Purpose | | ---------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CLOUDFLARE_API_TOKEN` | — | Zone-scoped API token with Analytics → Read | | `CLOUDFLARE_ZONE_ID` | — | 32-character hex zone id for the image hostname | | `CLOUDFLARE_HOST` | — | Optional. A Cloudflare zone can serve several subdomains and edge analytics only filter by path (`/img/*`), so set this to the keenpix hostname (e.g. `images.example.com`) when the zone also hosts other sites that use a `/img` path. Leave blank to count the whole zone. | ## Limits & performance [#limits--performance] | Variable | Default | Purpose | | -------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `KEENPIX_CACHE_MAX_BYTES` | app default: `2147483648` (2GB); Docker/Coolify: `8589934592` (8GB) | Disk cache LRU cap | | `KEENPIX_CACHE_STALE_MS` | `86400000` (24h) | Serve stale disk entries immediately and refresh them in the background; `0` disables stale refresh | | `KEENPIX_MEMORY_CACHE_MAX_BYTES` | app default: `67108864` (64MB); Docker/Coolify: `268435456` (256MB) | In-process hot variant LRU cap; `0` disables memory caching | | `KEENPIX_MAX_ORIGIN_BYTES` | `52428800` (50MB) | Max bytes pulled from an origin before `413` | | `KEENPIX_MAX_CONCURRENCY` | CPU count | Max simultaneous transforms before queueing | | `KEENPIX_MAX_QUEUE` | `100` | Queue depth before shedding load with `503` | | `KEENPIX_MAX_INPUT_PIXELS` | `50000000` | Decode-time pixel ceiling (decompression-bomb guard) | | `KEENPIX_MAX_DIMENSION` | `4096` | Longest output side when a request gives no `w`/`h` | | `KEENPIX_ORIGIN_TIMEOUT_MS` | `10000` | Per-attempt origin fetch timeout before `504` | ## Deploy Source: https://keenpix.com/docs/self-hosting Keenpix runs as a single Node container (sharp needs Node, not an edge runtime). Put a CDN in front of `/img/*`, then configure that CDN to cache the endpoint's immutable image responses. ## The 60-second deploy [#the-60-second-deploy] ```bash git clone keenpix && cd keenpix cp .env.example .env # set a strong secret: # BETTER_AUTH_SECRET=$(openssl rand -hex 32) # set POSTGRES_PASSWORD, KEENPIX_SUPER_ADMIN_EMAIL, and KEENPIX_SUPER_ADMIN_PASSWORD docker compose up -d --build # → http://localhost:3000 ``` `docker-compose.yml` ships the app plus Postgres. On boot the container runs `prisma migrate deploy`, seeds the default org and super admin user, then serves the dashboard and the transform endpoint as a non-root user. ## After deploy [#after-deploy] 1. Sign in as `KEENPIX_SUPER_ADMIN_EMAIL` with `KEENPIX_SUPER_ADMIN_PASSWORD`. 2. Create a project pointed at your image origin. 3. Add your source host under **Settings → Security → Allowed hosts**. 4. Invite any staff from **Settings → Staff**. Copy the invitation link, or send it by email after SMTP is configured in **Settings → Email**. Env SMTP values are only used as a fallback when Settings SMTP is disabled or incomplete. 5. Request `/img/https://your-origin.example/photo.jpg?project=ID&w=1200&fmt=webp` — no API key required for transform URLs. ## Architecture [#architecture] ```text Browser/CDN ──▶ Keenpix container (Node + sharp) ──▶ your image origin │ └── disk cache (volume) + Postgres (projects, request logs) ``` Scale horizontally behind a load balancer; the disk cache is per-instance, and CDN cache rules plus `immutable` responses keep origin traffic low. See [CDN setup](/docs/self-hosting/cdn) for Cloudflare, Nginx, and Caddy caching rules, [Deployment presets](/docs/self-hosting/presets) for ready-made `.env` files, and [Configuration](/docs/self-hosting/configuration) for every environment variable. ## Deployment presets Source: https://keenpix.com/docs/self-hosting/presets Three ready-made starting points for `.env`. Every variable is documented on the [Configuration](/docs/self-hosting/configuration) page — these presets just bundle sensible values for common deployment shapes. Always generate your own secret with `openssl rand -hex 32`. With the bundled `docker-compose.yml`, set `POSTGRES_PASSWORD` and Compose builds `DATABASE_URL` for the app automatically. Only set `DATABASE_URL` yourself for a non-Docker run (`pnpm start`) or an external database. ## Local-only (evaluation) [#local-only-evaluation] Running on your own machine to try Keenpix. No public URL, no CDN. ```bash POSTGRES_PASSWORD="postgres" BETTER_AUTH_SECRET="" BETTER_AUTH_URL="http://localhost:3000" KEENPIX_SUPER_ADMIN_EMAIL="admin@example.com" KEENPIX_SUPER_ADMIN_PASSWORD="" ``` * Plain `http` on localhost, so secure cookies stay off — fine for local only. * No SMTP needed: read the OTP code from `docker compose logs app`. ## Single-node production [#single-node-production] One server answers both the dashboard and every image transform over HTTPS. No separate CDN. ```bash POSTGRES_PASSWORD="" BETTER_AUTH_SECRET="" BETTER_AUTH_URL="https://keenpix.example.com" KEENPIX_APP_URL="https://keenpix.example.com" KEENPIX_SUPER_ADMIN_EMAIL="you@example.com" KEENPIX_SUPER_ADMIN_PASSWORD="" # Deliver staff invitations and OTP codes via SMTP (or use postmark / resend). EMAIL_PROVIDER="smtp" SMTP_HOST="smtp.example.com" SMTP_PORT="587" SMTP_USER="..." SMTP_PASSWORD="..." SMTP_FROM_EMAIL="keenpix@example.com" # This box answers every transform: give the disk cache room, bound concurrency. KEENPIX_CACHE_MAX_BYTES="10737418240" # 10 GB KEENPIX_MAX_CONCURRENCY="4" KEENPIX_MAX_QUEUE="200" ``` * An `https://` URL turns on secure cookies automatically. * Size `KEENPIX_CACHE_MAX_BYTES` to the disk volume; the in-memory LRU (`KEENPIX_MEMORY_CACHE_MAX_BYTES`) keeps the hottest variants in RAM. * Set `KEENPIX_MAX_CONCURRENCY` near the CPU count; load past `KEENPIX_MAX_QUEUE` sheds with `503` instead of overrunning the box. ## CDN-fronted production [#cdn-fronted-production] A CDN caches `/img/*` and absorbs repeat traffic, so Keenpix only does cold transforms. See [CDN setup](/docs/self-hosting/cdn). ```bash POSTGRES_PASSWORD="" BETTER_AUTH_SECRET="" # Point auth at the DASHBOARD host, not the image host the CDN serves. BETTER_AUTH_URL="https://admin.keenpix.example.com" KEENPIX_APP_URL="https://admin.keenpix.example.com" KEENPIX_SUPER_ADMIN_EMAIL="you@example.com" KEENPIX_SUPER_ADMIN_PASSWORD="" EMAIL_PROVIDER="smtp" SMTP_HOST="smtp.example.com" SMTP_PORT="587" SMTP_USER="..." SMTP_PASSWORD="..." SMTP_FROM_EMAIL="keenpix@example.com" # Cloudflare absorbs repeat traffic, but the origin is still the shared shield # for edge cold misses and tiered-cache fills. KEENPIX_CACHE_MAX_BYTES="8589934592" # 8 GB KEENPIX_MEMORY_CACHE_MAX_BYTES="268435456" # 256 MB KEENPIX_CACHE_STALE_MS="86400000" # 24 hours KEENPIX_MAX_CONCURRENCY="4" KEENPIX_MAX_QUEUE="200" ``` Scope the CDN's cache rule to `/img/*` only, or keep the dashboard on a host the CDN does not cache. Caching `/app` or `/api/auth` breaks sign-in. * Use omitted `fmt` / `fmt=auto` only when the CDN can vary by `Accept`. Otherwise, prefer explicit `fmt=webp` / `fmt=avif` in generated URLs to maximize edge hit rate, knowing that an explicit format will not fall back for unsupported browsers. * Use explicit `fmt=svg` for optimized SVG delivery. Omitted `fmt` / `fmt=auto` rasterizes SVG origins instead of preserving SVG output. * Size the origin disk cache to the image working set that still reaches Keenpix after Cloudflare. For small sites 2 GB can be enough; for high-cardinality hotel/CMS libraries, start around 8 GB and watch Operations for eviction churn.