# 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'
---
```
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
```
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
```
## Server-side (example: PHP) [#server-side-example-php]
```php
'YOUR_ID', 'w' => $w, 'fmt' => 'auto']);
return "https://keenpix.example.com/img/" . rawurlencode($src) . "?$q";
}
?>
```
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
```
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
}
```
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 (
)
}
```
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 (
)
}
```
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
```
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
```
## 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.