Skip to content

Serving

URL grammars. The positional route is /resize/{w}/{h}/{file}[@fmt]; 0 leaves an axis unconstrained, and {file} may span directories. Setting OXIMG_OPTIONS_PREFIX mounts a second route speaking the Cloudflare Images option grammar at that prefix:

/image/width=750,quality=80/albums/2026/photo.png

with width/height (1-8192; one suffices, the other axis follows the aspect ratio), quality (1-100, applied to whichever format the output resolves to; PNG output is lossless and ignores it), and format (jpeg|png|webp|avif, or auto = the same Accept negotiation as a bare positional URL, which also runs when format is absent). Unknown or duplicate options answer 400 naming the key — Cloudflare silently ignores unknown options, but a silently dropped fit=cover changes the output, so the divergence is deliberate. The filename is taken literally on this route (no @fmt token).

Sources. With OXIMG_SOURCE_BASE_URL unset, sources come from IMAGES_DIR. Set it and the scheme selects the transport:

  • https://host/prefix — anonymous HTTP. Exposure prerequisite: no credentials are sent, so the origin must be anonymously readable; for an object-store bucket that means public objects, and anyone who can guess a path can fetch the original at full resolution, bypassing every resize/signing/CDN control in front.
  • gs://bucket[/prefix] — a private GCS bucket, read directly with GCP-attached credentials (GKE Workload Identity, Cloud Run, and GCE metadata credentials; tokens cached and refreshed; boot fails closed with a clear message when no credentials are reachable). service_account JSON keys are not supported — on GCP use Workload Identity, off GCP use the HTTP mode. s3:// is planned (issue #11).

Remote sources are downloaded into a bounded buffer (OXIMG_MAX_SOURCE_BYTES) before the request takes a CPU slot, so the origin round trip never holds one — measured at ~50% of a permit’s hold time on a production corpus before the split (issue #20/#22). Download concurrency has its own bound, OXIMG_FETCH_CONCURRENCY, and local sources keep the streaming decode (no buffering, the page cache serves the read). Connection-level transients (reset, refused, DNS blips) are retried once before any body bytes are consumed, and the gs:// mode also retries 429/5xx SDK-style; oximg_upstream_retries_total counts both.

Format ceilings are part of the fit: WebP cannot express a side past 16383 px, so a request whose output would exceed that is scaled down until it fits, aspect ratio preserved — a 2000x19708 source asked for width=1920 as WebP comes back 1663x16383. Tall single-column images (infographics, long product pages) hit this routinely, and the alternative is failing a request the format simply cannot serve at the asked-for size. The returned image reports its own dimensions; other output formats have no ceiling worth enforcing here (their limits sit past OXIMG_MAX_SRC_PIXELS).

Error classes follow fault, not convenience: a source key that no store can serve — past an object store’s key-length limit, or refused by the origin as a malformed request (400/414) — answers 400, and an absent object 404. Only a genuinely unwell upstream (connect failure, reset, 5xx) answers 502, with slow origins split off as 504. This matters downstream: CDNs retry and fail over on 5xx but pass 4xx through to their error cache, so misfiling a client error as an upstream failure both inflates the 5xx rate an operator watches and turns a crawler into origin load. oximg_upstream_fetch_total splits the same way (rejected and not_found apart from error), so that series stays a signal of upstream health. Over-length keys are refused locally, without a round trip.

Source paths are validated component-wise — ./.. components, empty components, \, ?, #, and control bytes answer 400. Local sources also pass a symlink-containment check (a path resolving outside IMAGES_DIR answers 404), and remote paths are re-encoded segment-wise so a percent in a name is never double-decoded upstream.

URL signing (optional): set OXIMG_KEY and OXIMG_SALT (hex) to require imgproxy-style signed URLs — /{base64url(HMAC-SHA256(key, salt || path))}/resize/{w}/{h}/{file}, and the same scheme over {prefix}/{options}/{file} on the options route. The signed path is the percent-decoded form, so one signature covers every URL encoding of the same source.

CORS preflight: OPTIONS on an image route answers 204 with Allow: GET, HEAD, OPTIONS, because a browser preflight requires a 2xx — a 405 fails it no matter what CORS headers a CDN attaches, since the status itself is the blocker. Preflights are not signature-checked (they perform no work and answer identically for every path; the GET that follows still is). oximg does not emit the CORS response headers themselves — Access-Control-Allow-Origin and friends come from whatever fronts it. Other methods still answer 405.

Graceful shutdown: on SIGTERM (what docker stop, Kubernetes, and Cloud Run send) or SIGINT the server stops accepting connections, finishes in-flight requests, and exits 0. There is no drain timeout of its own — the orchestrator’s grace period backstops a response that never finishes, so allow a few seconds more than your slowest expected encode.