# zimgx > Fast, single-binary image proxy and transformation server. A self-hosted drop-in replacement for Cloudflare Images. ## Architecture zimgx is a single-threaded HTTP image proxy. It fetches images from a configurable origin, transforms them with libvips, and serves results through a multi-tier cache. ### Request flow ``` Client Request │ ▼ ┌──────────┐ Parse the target path. Detect well-known │ Router │───▶ routes (/health, /ready, /metrics) └────┬─────┘ or extract image path and transform string. │ ▼ ┌──────────┐ Parse comma-separated key=value pairs │ Params │───▶ into a TransformParams struct. Validate │ Parser │ ranges for w, h, q, fit, g, and others. └────┬─────┘ │ ▼ ┌──────────┐ Build a deterministic key: || │ Cache │───▶ Check L1 (memory), then L2 (R2) if configured. │ Lookup │ Hit → serve with ETag and Cache-Control. └────┬─────┘ │ miss ▼ ┌──────────┐ HTTP GET to base URL + path, or │ Origin │───▶ S3 GET from the R2 originals bucket. │ Fetch │ └────┬─────┘ │ ▼ ┌──────────┐ 1. Probe (detect animation, load first frame) │ Transform │───▶ 2. Decide and reload (animated or static path) │ Pipeline │ 3. Resize → Effects → Encode │ (libvips) │ └────┬─────┘ │ ▼ ┌──────────┐ Write to L1 (memory) and L2 (R2) if configured. │ Cache │ │ Store │ └────┬─────┘ │ ▼ Response 200 with image body, ETag, Cache-Control, Vary: Accept ``` ### Module map ``` src/ ├── main.zig Entry point. Initializes libvips, starts server. ├── server.zig HTTP server loop and request dispatch. ├── router.zig URL routing. Parses paths into a Route union. ├── config.zig ZIMGX_* env var loading and validation. ├── allocator.zig Per-request arena allocator. │ ├── transform/ │ ├── params.zig TransformParams struct and query string parser. │ ├── negotiate.zig Accept header parsing and format negotiation. │ └── pipeline.zig Probe → decide → reload → resize → effects → encode. │ ├── http/ │ ├── response.zig Content-Type, ETag, Cache-Control, 304 logic. │ └── errors.zig Structured HTTP errors with JSON serialization. │ ├── cache/ │ ├── cache.zig Cache vtable interface and cache key builder. │ ├── memory.zig In-memory LRU cache (thread-safe). │ ├── noop.zig No-op cache (used when caching is disabled). │ ├── r2.zig R2/S3-backed cache (L2 persistent layer). │ └── tiered.zig L1 plus L2 composition (memory → R2). │ ├── origin/ │ ├── source.zig URL builder for HTTP origin. │ ├── fetcher.zig HTTP client for origin fetches. │ └── r2.zig R2-backed origin fetcher. │ ├── s3/ │ ├── signing.zig AWS SigV4 signing (pure crypto, no I/O). │ └── client.zig S3 HTTP client (GET/PUT/DELETE/HEAD). │ └── vips/ └── bindings.zig C interop wrappers for libvips functions. ``` *** ### Cache tiers zimgx uses a polymorphic `Cache` interface (vtable pattern) with multiple backends: ``` ┌─────────────────────────────────────────┐ │ TieredCache │ │ │ │ ┌──────────────┐ ┌─────────────────┐ │ │ │ L1: Memory │ │ L2: R2 (S3) │ │ │ │ (MemoryCache)│ │ (R2Cache) │ │ │ │ │ │ │ │ │ │ - LRU evict │ │ - Persistent │ │ │ │ - Size-bound │ │ - Cross-restart│ │ │ │ - Fastest │ │ - Shared │ │ │ └──────────────┘ └─────────────────┘ │ └─────────────────────────────────────────┘ ``` **Read path:** L1 hit → return. L1 miss → check L2 → promote to L1 → return. **Write path:** Write to both L1 and L2. **Delete path:** Delete from both L1 and L2. When `ZIMGX_CACHE_ENABLED=false`, a `NoopCache` replaces all cache operations with no-ops. When `ZIMGX_ORIGIN_TYPE=http` (no R2 configured), only the L1 memory cache is active. *** ### Origin backends #### HTTP (`ZIMGX_ORIGIN_TYPE=http`) Appends the request path to `ZIMGX_ORIGIN_BASE_URL`: ``` Request: GET /photos/cat.jpg/w=400 Origin: GET https://images.example.com/photos/cat.jpg ``` #### R2 (`ZIMGX_ORIGIN_TYPE=r2`) Fetches from a Cloudflare R2 bucket using AWS SigV4 signed requests: ``` Request: GET /photos/cat.jpg/w=400 Origin: S3 GET from bucket "originals", key "photos/cat.jpg" ``` With R2 origin, the tiered cache uses a second R2 bucket (`ZIMGX_R2_BUCKET_VARIANTS`) as the L2 persistent layer. *** ### Transform pipeline The pipeline uses a probe-decide-reload flow for animation-aware processing: ``` ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ Probe │───▶│ Decide │───▶│ Reload │───▶│ Resize │───▶│ Effects │───▶│ Encode │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ ``` #### 1. Probe Loads only the first frame to detect animation metadata via `n-pages`. #### 2. Decide Determines whether to produce animated output based on: animation mode, frame extraction, output format support, pixel budget, and the `Accept` header. #### 3. Reload For animated output: unrefs the probe image and reloads all frames stacked vertically. Clamps frame count to `max_frames`. For static output: no-op. #### 4. Resize Uses `vips_thumbnail_image` with fit mode mapping: | `fit` value | libvips behavior | | ----------- | ---------------------------------------------------- | | `contain` | `VIPS_SIZE_DOWN` — scale down, preserve aspect ratio | | `cover` | Crop to fill using gravity for the crop anchor | | `fill` | `VIPS_SIZE_FORCE` — stretch to exact dimensions | | `inside` | `VIPS_SIZE_DOWN` — same as contain | | `outside` | `VIPS_SIZE_UP` — scale up to cover dimensions | #### 5. Effects Applied in order: sharpen → blur → brightness/contrast → gamma → saturation. #### 6. Encode | Format | Encoder | Quality | | ------ | ---------------------- | --------------------------- | | JPEG | `vips_jpegsave_buffer` | 1–100 | | PNG | `vips_pngsave_buffer` | Compression level 6 (fixed) | | WebP | `vips_webpsave_buffer` | 1–100 | | AVIF | `vips_heifsave_buffer` | 1–100 | | GIF | `vips_gifsave_buffer` | Palette-based | *** ### Error handling Errors serialize to JSON: ```json {"error":{"status":404,"message":"Not Found","detail":"image not found at origin"}} ``` | Condition | HTTP status | | ----------------------------- | ------------------------ | | Invalid transform parameters | 400 Bad Request | | Transform values out of range | 422 Unprocessable Entity | | Image not found at origin | 404 Not Found | | Origin server timed out | 504 Gateway Timeout | | Image exceeds size limit | 413 Payload Too Large | | Origin fetch failed | 502 Bad Gateway | *** ### Single-threaded design zimgx runs a single-threaded accept loop. This is a deliberate choice: * **Simplicity** — No mutexes and no data races. Server-owned buffers are safely reused between requests. * **libvips threading** — libvips uses its own internal thread pool for image operations. The single Zig thread dispatches work to libvips, which parallelizes the heavy compute. * **Scaling** — Run multiple instances behind a load balancer. Each instance maintains its own L1 cache. The shared R2 L2 cache provides cross-instance persistence. ## Configuration zimgx reads all configuration from environment variables prefixed with `ZIMGX_`. Every variable is optional and has a sensible default. ### Server | Variable | Type | Default | Description | | --------------------------------- | -------- | ------------------- | ------------------------------------------------------------------- | | `ZIMGX_SERVER_PORT` | `u16` | `8080` | TCP port to listen on | | `ZIMGX_SERVER_HOST` | `string` | `0.0.0.0` | IP address to bind to. Set to `127.0.0.1` to restrict to localhost. | | `ZIMGX_SERVER_REQUEST_TIMEOUT_MS` | `u32` | `30000` | Maximum time (ms) to wait for a complete request | | `ZIMGX_SERVER_MAX_REQUEST_SIZE` | `usize` | `52428800` (50 MiB) | Maximum request/response body size in bytes | ### Origin | Variable | Type | Default | Description | | -------------------------- | -------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ZIMGX_ORIGIN_TYPE` | `string` | `http` | `http` fetches from `ZIMGX_ORIGIN_BASE_URL`. `r2` fetches from an R2 bucket. | | `ZIMGX_ORIGIN_BASE_URL` | `string` | `http://localhost:9000` | Base URL for the HTTP origin. The image path is appended to this value. | | `ZIMGX_ORIGIN_TIMEOUT_MS` | `u32` | `10000` | Timeout (ms) for origin fetch requests | | `ZIMGX_ORIGIN_MAX_RETRIES` | `u8` | `2` | Retry attempts for failed origin fetches | | `ZIMGX_ORIGIN_PATH_PREFIX` | `string` | `""` | Path prefix to strip from image paths before fetching from origin. When set, requests for `//` resolve to origin key ``. Useful for Cloudflare Images migration where URLs include the account ID. | ### R2/S3 Required when `ZIMGX_ORIGIN_TYPE=r2`. All fields must be non-empty. | Variable | Type | Default | Description | | ---------------------------- | -------- | ----------- | ------------------------------------------------- | | `ZIMGX_R2_ENDPOINT` | `string` | `""` | R2/S3-compatible endpoint URL | | `ZIMGX_R2_ACCESS_KEY_ID` | `string` | `""` | Access key ID | | `ZIMGX_R2_SECRET_ACCESS_KEY` | `string` | `""` | Secret access key | | `ZIMGX_R2_BUCKET_ORIGINALS` | `string` | `originals` | Bucket for source images | | `ZIMGX_R2_BUCKET_VARIANTS` | `string` | `variants` | Bucket for cached transformed variants (L2 cache) | ### Transform limits | Variable | Type | Default | Description | | ------------------------------------- | ------ | ---------- | ----------------------------------------------------------------------------------------------------------------- | | `ZIMGX_TRANSFORM_MAX_WIDTH` | `u32` | `8192` | Maximum output width in pixels | | `ZIMGX_TRANSFORM_MAX_HEIGHT` | `u32` | `8192` | Maximum output height in pixels | | `ZIMGX_TRANSFORM_DEFAULT_QUALITY` | `u8` | `80` | Default JPEG/WebP/AVIF quality when `q` is not specified | | `ZIMGX_TRANSFORM_MAX_PIXELS` | `u64` | `71000000` | Maximum total pixel count (width x height). Images exceeding this are rejected. | | `ZIMGX_TRANSFORM_STRIP_METADATA` | `bool` | `true` | Strip EXIF and other metadata from output. Accepts `true`/`1` or `false`/`0`. | | `ZIMGX_TRANSFORM_MAX_FRAMES` | `u32` | `100` | Maximum frames to load from animated images. Excess frames are truncated. | | `ZIMGX_TRANSFORM_MAX_ANIMATED_PIXELS` | `u64` | `50000000` | Maximum total pixels across all frames. Animated images exceeding this budget are served as a static first frame. | ### Cache | Variable | Type | Default | Description | | --------------------------------- | ------- | --------------------- | -------------------------------------------------------------------------------- | | `ZIMGX_CACHE_ENABLED` | `bool` | `true` | Enable the in-memory L1 cache. When disabled, every request fetches from origin. | | `ZIMGX_CACHE_MAX_SIZE_BYTES` | `usize` | `536870912` (512 MiB) | Maximum memory for the L1 cache. Entries are evicted using LRU when exceeded. | | `ZIMGX_CACHE_DEFAULT_TTL_SECONDS` | `u32` | `3600` | TTL for `Cache-Control: public, max-age=` headers on image responses | ### Validation zimgx validates configuration at startup and refuses to start if: * `ZIMGX_SERVER_PORT` is 0 * `ZIMGX_SERVER_REQUEST_TIMEOUT_MS` or `ZIMGX_ORIGIN_TIMEOUT_MS` is 0 * `ZIMGX_TRANSFORM_MAX_WIDTH` or `ZIMGX_TRANSFORM_MAX_HEIGHT` is 0 * `ZIMGX_TRANSFORM_DEFAULT_QUALITY` is outside 1–100 * `ZIMGX_ORIGIN_TYPE=http` and `ZIMGX_ORIGIN_BASE_URL` is empty * `ZIMGX_ORIGIN_TYPE=r2` and any R2 field is empty ### Example **.env** file ```sh # Origin ZIMGX_ORIGIN_TYPE=r2 # R2 ZIMGX_R2_ENDPOINT=https://0bc82bff4439c556f9dc1b054d2de6d7.r2.cloudflarestorage.com ZIMGX_R2_ACCESS_KEY_ID=your-access-key ZIMGX_R2_SECRET_ACCESS_KEY=your-secret-key ZIMGX_R2_BUCKET_ORIGINALS=originals ZIMGX_R2_BUCKET_VARIANTS=variants # Server ZIMGX_SERVER_PORT=8080 ZIMGX_SERVER_HOST=0.0.0.0 # Cache ZIMGX_CACHE_ENABLED=true ZIMGX_CACHE_MAX_SIZE_BYTES=536870912 ZIMGX_CACHE_DEFAULT_TTL_SECONDS=3600 # Transform limits ZIMGX_TRANSFORM_DEFAULT_QUALITY=80 ZIMGX_TRANSFORM_STRIP_METADATA=true ZIMGX_TRANSFORM_MAX_FRAMES=100 ZIMGX_TRANSFORM_MAX_ANIMATED_PIXELS=50000000 ``` ## Deployment ### Docker zimgx ships as a multi-arch Docker image (`linux/amd64`, `linux/arm64`) built on Alpine Linux. #### Pull from GHCR ```sh docker pull ghcr.io/officialunofficial/zimgx:latest ``` #### HTTP origin ```sh docker run -p 8080:8080 \ -e ZIMGX_ORIGIN_TYPE=http \ -e ZIMGX_ORIGIN_BASE_URL=https://images.example.com \ ghcr.io/officialunofficial/zimgx:latest ``` #### R2 origin ```sh docker run -p 8080:8080 \ -e ZIMGX_ORIGIN_TYPE=r2 \ -e ZIMGX_R2_ENDPOINT=https://.r2.cloudflarestorage.com \ -e ZIMGX_R2_ACCESS_KEY_ID=your-access-key \ -e ZIMGX_R2_SECRET_ACCESS_KEY=your-secret-key \ -e ZIMGX_R2_BUCKET_ORIGINALS=originals \ -e ZIMGX_R2_BUCKET_VARIANTS=variants \ ghcr.io/officialunofficial/zimgx:latest ``` #### Environment file ```sh docker run -p 8080:8080 --env-file .env ghcr.io/officialunofficial/zimgx:latest ``` #### Build locally ```sh docker build -t zimgx . docker run -p 8080:8080 -e ZIMGX_ORIGIN_BASE_URL=https://images.example.com zimgx ``` The Dockerfile uses a two-stage build: 1. **Build stage** — Alpine with Zig and **vips-dev**, compiles with `ReleaseSafe` 2. **Runtime stage** — Alpine with only the **vips** runtime library (approximately 50 MiB image) *** ### Docker Compose ```yaml services: zimgx: image: ghcr.io/officialunofficial/zimgx:latest ports: - "8080:8080" environment: ZIMGX_ORIGIN_TYPE: http ZIMGX_ORIGIN_BASE_URL: https://images.example.com ZIMGX_CACHE_ENABLED: "true" ZIMGX_CACHE_MAX_SIZE_BYTES: "536870912" ZIMGX_SERVER_PORT: "8080" healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"] interval: 10s timeout: 5s retries: 3 start_period: 5s restart: unless-stopped ``` *** ### Build from source Requirements: * Zig 0.15.0 or later * libvips 8.18.0 or later (with development headers) #### macOS ```sh brew install vips zig build -Doptimize=ReleaseSafe ./zig-out/bin/zimgx ``` #### Alpine Linux ```sh apk add vips-dev zig build -Doptimize=ReleaseSafe ./zig-out/bin/zimgx ``` *** ### Health checks and probes zimgx exposes three endpoints for monitoring. #### `/health` Returns `200` with `{"status":"ok"}` when the server is running. Use this for Docker `HEALTHCHECK`, load balancer health checks, and uptime monitors. ```sh curl http://localhost:8080/health # {"status":"ok"} ``` #### `/ready` Returns `200` with `{"ready":true}` when the server is ready to accept requests. Use this for Kubernetes readiness probes. ```sh curl http://localhost:8080/ready # {"ready":true} ``` #### `/metrics` Returns `200` with JSON statistics: ```json { "requests_total": 1042, "cache_hits": 891, "cache_misses": 151, "cache_entries": 148, "uptime_seconds": 3600 } ``` | Field | Description | | ---------------- | ---------------------------------------- | | `requests_total` | Total HTTP requests served since startup | | `cache_hits` | Requests served from cache | | `cache_misses` | Requests that required an origin fetch | | `cache_entries` | Current entries in the L1 memory cache | | `uptime_seconds` | Seconds since server startup | *** ### Kubernetes ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: zimgx spec: replicas: 2 selector: matchLabels: app: zimgx template: metadata: labels: app: zimgx spec: containers: - name: zimgx image: ghcr.io/officialunofficial/zimgx:latest ports: - containerPort: 8080 env: - name: ZIMGX_ORIGIN_TYPE value: "http" - name: ZIMGX_ORIGIN_BASE_URL value: "https://images.example.com" livenessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 5 periodSeconds: 10 readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 3 periodSeconds: 5 resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "1Gi" cpu: "1000m" ``` *** ### CDN zimgx is designed to sit behind a CDN. Image responses include: * `Cache-Control: public, max-age=` — controlled by `ZIMGX_CACHE_DEFAULT_TTL_SECONDS` * `ETag` — content-based hash for conditional requests (`304 Not Modified`) * `Vary: Accept` — tells the CDN to cache separate variants per format negotiation Configure your CDN to forward the `Accept` header and respect `Vary: Accept` for correct content negotiation. ## Getting started ### Build from source ```bash # Requires: Zig 0.14+, libvips, glib headers zig build -Doptimize=ReleaseSafe ``` This produces a binary at **zig-out/bin/zimgx**. ### Configure an origin Tell zimgx where your images live. Pick one of the following options. #### HTTP origin Use this if your images are behind any web server, S3 presigned URLs, or an existing CDN: ```bash export ZIMGX_ORIGIN_TYPE=http export ZIMGX_ORIGIN_BASE_URL=https://my-bucket.s3.amazonaws.com ``` #### S3/R2 origin Use this for direct bucket access with any S3-compatible storage — Cloudflare R2, AWS S3, DigitalOcean Spaces, MinIO, and others: ```bash export ZIMGX_ORIGIN_TYPE=r2 export ZIMGX_R2_ENDPOINT=https://.r2.cloudflarestorage.com export ZIMGX_R2_ACCESS_KEY_ID=your-access-key export ZIMGX_R2_SECRET_ACCESS_KEY=your-secret-key export ZIMGX_R2_BUCKET_ORIGINALS=my-images ``` ### Start the server ```bash ./zig-out/bin/zimgx # Listening on 0.0.0.0:8080 ``` ### Request an image Assuming your origin has a file at the key `photos/hero.jpg`: ```bash # Original, no transforms curl http://localhost:8080/photos/hero.jpg # Resize to 400px wide curl http://localhost:8080/photos/hero.jpg/w=400 # Resize and convert to WebP at quality 85 curl http://localhost:8080/photos/hero.jpg/w=800,f=webp,q=85 ``` No upload API. No database. Your images stay where they are. *** ### Use Docker instead Skip the build step entirely: ```bash docker pull ghcr.io/officialunofficial/zimgx:latest docker run -p 8080:8080 \ -e ZIMGX_ORIGIN_TYPE=http \ -e ZIMGX_ORIGIN_BASE_URL=https://images.example.com \ ghcr.io/officialunofficial/zimgx:latest ``` *** ### Caching zimgx caches transformed images automatically. Repeated requests skip the origin fetch and transformation step entirely. **In-memory (default):** A 512 MiB LRU cache with no setup required. **Tiered (memory plus R2):** A fast memory layer backed by persistent R2 storage. ```bash export ZIMGX_CACHE_ENABLED=true export ZIMGX_CACHE_MAX_SIZE_BYTES=536870912 # 512 MiB export ZIMGX_CACHE_DEFAULT_TTL_SECONDS=3600 # 1 hour Cache-Control ``` zimgx generates ETags automatically. Clients that send `If-None-Match` receive `304 Not Modified` responses, saving bandwidth. *** ### Health and monitoring ```bash # Health check curl http://localhost:8080/health # {"status":"ok"} # Readiness probe curl http://localhost:8080/ready # {"ready":true} # Server metrics curl http://localhost:8080/metrics # Request counts, cache hit/miss rates, uptime ``` *** ### Next steps * [Transform parameters](/transforms) — Full reference for resize, format, and effect options * [Configuration](/configuration) — Complete environment variable reference * [Deployment](/deployment) — Docker, Kubernetes, and CDN setup * [Migrating from Cloudflare](/migrating-from-cloudflare) — Replace imagedelivery.net with zimgx ## Migrating from Cloudflare Images This page covers how to move from Cloudflare Images (imagedelivery.net) to zimgx. It maps Cloudflare's URL parameters to zimgx equivalents and outlines the differences between the two. *** ### URL mapping **Cloudflare Images:** ``` https://imagedelivery.net/// ``` **zimgx:** ``` https://your-domain.com// ``` The zimgx URL scheme is compatible with Cloudflare's path structure. A request for `///` works out of the box — zimgx treats everything before the last `=`-containing segment as the image path. Set `ZIMGX_ORIGIN_PATH_PREFIX` to your account ID so zimgx strips it when fetching from origin. *** ### Parameter translation | Cloudflare | zimgx | Notes | | -------------- | -------------------------- | --------------------------------- | | `width=400` | `w=400` | Shorter syntax | | `height=300` | `h=300` | | | `quality=85` | `q=85` | | | `format=webp` | `f=webp` | | | `format=auto` | `f=auto` (default) | Auto-negotiation is on by default | | `fit=cover` | `fit=cover` | Same values | | `gravity=auto` | `g=smart` or `g=attention` | Content-aware crop via libvips | | `sharpen=1` | `sharpen=1.0` | | | `blur=5` | `blur=5.0` | | *** ### Feature comparison | Feature | Cloudflare Images | zimgx | | ------------------ | ------------------------------ | ------------------------------------------------------- | | Image upload | Required (via API) | Not needed — reads from your origin | | Storage | Managed by Cloudflare | Your S3/R2 bucket or HTTP server | | CDN | Built-in global CDN | Place any CDN in front (Cloudflare, Fastly, and others) | | Format negotiation | Automatic | Automatic (via `Accept` header) | | Caching | CDN cache | In-memory LRU with optional R2 tier | | Named variants | Yes (dashboard/API) | No — transforms live in the URL | | Pricing | Per-image stored and delivered | Self-hosted — your infrastructure costs | | Animation | Native GIF/AVIF support | GIF/WebP with frame control | | Self-hosted | No (managed service) | Yes (single binary) | *** ### Migration steps #### 1. Keep your images where they are If your images already live in R2 or S3, point zimgx at that bucket directly. You do not need to move anything. #### 2. Deploy zimgx ```bash docker run -p 8080:8080 \ -e ZIMGX_ORIGIN_TYPE=r2 \ -e ZIMGX_R2_ENDPOINT=https://.r2.cloudflarestorage.com \ -e ZIMGX_R2_ACCESS_KEY_ID=... \ -e ZIMGX_R2_SECRET_ACCESS_KEY=... \ -e ZIMGX_R2_BUCKET_ORIGINALS=my-images \ ghcr.io/officialunofficial/zimgx:latest ``` #### 3. Strip the account ID prefix Cloudflare Images URLs include your account ID as the first path segment: `///`. Your origin storage stores images by `` alone. Set `ZIMGX_ORIGIN_PATH_PREFIX` to your Cloudflare account ID so zimgx strips it before fetching from origin: ```bash docker run -p 8080:8080 \ -e ZIMGX_ORIGIN_TYPE=r2 \ -e ZIMGX_ORIGIN_PATH_PREFIX=abc123 \ -e ZIMGX_R2_ENDPOINT=https://.r2.cloudflarestorage.com \ -e ZIMGX_R2_ACCESS_KEY_ID=... \ -e ZIMGX_R2_SECRET_ACCESS_KEY=... \ -e ZIMGX_R2_BUCKET_ORIGINALS=my-images \ ghcr.io/officialunofficial/zimgx:latest ``` With this setting, a request for `/abc123/photo-id/w=400` fetches origin key `photo-id` instead of `abc123/photo-id`. #### 4. Update your image URLs Replace Cloudflare Image URLs in your app: ``` # Before (Cloudflare) https://imagedelivery.net/abc123/photo-id/w=400,format=auto # After (with ZIMGX_ORIGIN_PATH_PREFIX=abc123, keep the same path) https://your-zimgx-host.com/abc123/photo-id/w=400 # Or without the prefix https://your-zimgx-host.com/photo-id/w=400 ``` Format auto-negotiation is on by default. You do not need to specify `f=auto`. #### 5. Place a CDN in front For production, put a CDN or reverse proxy in front of zimgx: ``` Client → CDN (Cloudflare, Fastly, nginx) → zimgx → R2/S3 ``` zimgx sets `Cache-Control` and `ETag` headers automatically. The CDN caches responses at the edge. zimgx's internal cache handles the origin layer. ## Transform parameters Specify transforms as the last path segment in the URL. zimgx treats a segment as a transform string when it contains `=`. Separate multiple parameters with commas. ``` GET // GET /photos/hero.jpg/w=400,h=300,f=webp,q=85 ``` ### Dimensions #### `w` / `width` Output width in pixels, resized according to the current `fit` mode. * **Range:** 1–8192 * **Default:** original width ``` /photo.jpg/w=400 /photo.jpg/width=400 ``` #### `h` / `height` Output height in pixels. * **Range:** 1–8192 * **Default:** original height ``` /photo.jpg/h=300 /photo.jpg/w=400,h=300 ``` When you specify only one dimension, zimgx derives the other from the source aspect ratio. #### `dpr` Device pixel ratio. Multiplies `w` and `h` for retina and HiDPI displays. The effective dimensions are clamped to 8192. * **Range:** 1.0–5.0 * **Default:** `1.0` ``` # 400px at 2x = 800px actual output /photo.jpg/w=400,dpr=2 ``` ### Quality #### `q` / `quality` Compression quality for lossy formats (JPEG, WebP, AVIF). Higher values produce larger files with fewer artifacts. Has no effect on PNG. * **Range:** 1–100 * **Default:** `80` ``` /photo.jpg/q=90 /photo.jpg/w=400,q=60 ``` ### Format #### `f` / `fmt` / `format` Output format. When set to `auto` or omitted, zimgx negotiates the format from the client's `Accept` header. * **Values:** `jpeg`, `jpg`, `png`, `webp`, `avif`, `gif`, `auto` * **Default:** `auto` ``` /photo.jpg/f=webp /photo.jpg/format=avif /photo.jpg/fmt=png /animation.gif/f=gif ``` ### Fit mode #### `fit` Controls how the image fits the target dimensions. | Value | Behavior | | --------- | ---------------------------------------------------------------------------------------------- | | `contain` | Scale down to fit within the dimensions. Preserves aspect ratio. Never upscales. **(default)** | | `cover` | Scale and crop to fill the exact dimensions. Uses `gravity` to pick the crop anchor. | | `fill` | Stretch to exactly fill the dimensions. Ignores aspect ratio. | | `inside` | Same as `contain`. | | `outside` | Scale up to cover the dimensions. Preserves aspect ratio. | ``` /photo.jpg/w=400,h=400,fit=cover /photo.jpg/w=400,h=300,fit=fill ``` ### Gravity #### `g` / `gravity` When `fit=cover` crops the image, this controls which region to keep. | Value | Aliases | Behavior | | ----------- | -------- | ------------------------------------------------------------ | | `center` | `centre` | Crop from center **(default)** | | `north` | `n` | Keep the top edge | | `south` | `s` | Keep the bottom edge | | `east` | `e` | Keep the right edge | | `west` | `w` | Keep the left edge | | `northeast` | `ne` | Keep the top-right corner | | `northwest` | `nw` | Keep the top-left corner | | `southeast` | `se` | Keep the bottom-right corner | | `southwest` | `sw` | Keep the bottom-left corner | | `smart` | — | Entropy-based detection (keeps high-detail areas) | | `attention` | `att` | Attention-based detection (keeps visually interesting areas) | ``` /photo.jpg/w=400,h=400,fit=cover,g=smart /photo.jpg/w=400,h=400,fit=cover,g=north ``` :::info For content-aware cropping, use `smart` or `attention`. Directional values (`north`, `south`, and others) fall back to center cropping. ::: ### Effects #### `sharpen` Unsharp mask with the given sigma value. * **Range:** 0.0–10.0 ``` /photo.jpg/sharpen=1.5 /photo.jpg/w=400,sharpen=2.0 ``` #### `blur` Gaussian blur with the given sigma value. * **Range:** 0.1–250.0 ``` /photo.jpg/blur=3.0 /photo.jpg/w=400,blur=10.0 ``` #### `brightness` * **Range:** 0.0–2.0 (1.0 is normal) ``` /photo.jpg/brightness=1.2 ``` #### `contrast` * **Range:** 0.0–2.0 (1.0 is normal) ``` /photo.jpg/contrast=1.3 ``` #### `saturation` * **Range:** 0.0–2.0 (1.0 is normal) ``` /photo.jpg/saturation=0.0 ``` #### `gamma` Gamma correction. * **Range:** 0.1–10.0 ``` /photo.jpg/gamma=2.2 ``` #### `rotate` * **Values:** 0, 90, 180, 270 ``` /photo.jpg/rotate=90 ``` #### `flip` * **Values:** `h` (horizontal), `v` (vertical), `hv` (both) ``` /photo.jpg/flip=h ``` #### `bg` / `background` Hex RGB color to flatten alpha channels onto before encoding. Useful when converting transparent PNGs to JPEG. * **Format:** 6-character hex, no `#` prefix ``` /photo.png/f=jpeg,bg=ffffff ``` #### `trim` Detect and crop uniform borders around the image. * **Range:** 1.0–100.0 (threshold) ``` /photo.jpg/trim=10 ``` #### `metadata` Controls EXIF and ICC metadata in the output. * **Values:** `strip` (default), `keep`, `copyright` ``` /photo.jpg/metadata=keep ``` ### Animation #### `anim` Controls whether animated images (GIF, animated WebP) preserve animation in the output. | Value | Aliases | Behavior | | --------- | ------- | ---------------------------------------------------------------------------------------------- | | `auto` | `true` | Preserve animation when the input is animated and the output format supports it. **(default)** | | `static` | `false` | Strip animation. Serve the first frame only. | | `animate` | — | Request animated output. Degrade to static if the format does not support animation. | ```bash # Serve an animated GIF, resized /spinner.gif/w=64 # Strip animation (Cloudflare-compatible syntax) /spinner.gif/anim=false # Strip animation (zimgx syntax) /spinner.gif/anim=static ``` When `anim=auto` and no explicit format is set, zimgx negotiates an animated-capable format from the `Accept` header: **WebP > GIF**. If the client accepts neither, the output degrades to a static first frame in the best available format. #### `frame` Extract a specific 0-indexed frame from an animated image and serve it as a static image. * **Range:** 0–999 * **Default:** none (all frames preserved) ``` /spinner.gif/frame=0,f=png /spinner.gif/frame=2 ``` If the index exceeds the number of frames, zimgx returns the last frame. #### Animation safety limits | Limit | Default | Environment variable | | ------------------------------------------ | ---------- | ------------------------------------- | | Max frames | 100 | `ZIMGX_TRANSFORM_MAX_FRAMES` | | Max total pixels (width x height x frames) | 50,000,000 | `ZIMGX_TRANSFORM_MAX_ANIMATED_PIXELS` | When the pixel budget is exceeded, the output falls back to a static first frame. When the frame count exceeds `max_frames`, only that many frames load. *** ### Content negotiation When the output format is `auto` (the default), zimgx picks the best format from the client's `Accept` header. **Static images:** AVIF > WebP > JPEG > PNG **Animated images:** WebP > GIF The negotiation follows these rules: 1. **Explicit format wins.** If you set `f=webp`, zimgx always outputs WebP regardless of `Accept`. 2. **Animation preservation.** For animated sources, zimgx prefers animated-capable formats (WebP, GIF). 3. **Alpha channel handling.** When the source has transparency, JPEG is deprioritized because it cannot represent alpha. 4. **Client support.** Only formats the client advertises in `Accept` are considered. 5. **Fallback.** If no acceptable format matches, JPEG is the universal fallback. The response includes a `Vary: Accept` header so CDNs cache different format variants correctly. *** ### Caching behavior zimgx caches transform results using a deterministic key built from the image path, transform parameters, and resolved output format. **Key format:** `||` **Example:** `photos/hero.jpg|w=400,h=300|webp` Parameter order does not matter. `w=400,h=300` and `h=300,w=400` produce the same cache key. *** ### Error responses Invalid transforms return structured JSON errors: | Condition | Status | Example | | ----------------- | ------ | ------------------------ | | Unknown parameter | 400 | `banana=42` | | Empty value | 400 | `w=` | | Invalid format | 400 | `f=bmp` | | Out of range | 422 | `w=0`, `w=9000`, `q=101` | ```json {"error":{"status":400,"message":"Bad Request","detail":"invalid transform parameters"}} ``` *** ### Full example ``` GET /products/shoe-red.png/w=800,h=600,fit=cover,g=attention,f=auto,q=85,dpr=2,sharpen=0.5 ``` This request: 1. Fetches `products/shoe-red.png` from the origin 2. Resizes to 1600x1200 effective pixels (800x600 at dpr 2) 3. Crops to fill using attention-based gravity 4. Negotiates the output format from the client's `Accept` header 5. Encodes at quality 85 6. Applies a light sharpen (sigma 0.5) 7. Caches the result for subsequent requests