Image Transformations
AnvilBase bundles imgproxy for on-the-fly image processing — resize, crop, change format, adjust quality — so you store one original and serve any variant without keeping multiple copies.
How it works
You request a transform through the control plane at
/storage/v1/render/image/{authenticated|public}/<bucket>/<path>. The control
plane signs an imgproxy URL (HMAC over the processing options + the object’s
S3 location), fetches the transformed image from the internal imgproxy sidecar
server-side, and streams it back through the control-plane origin. The response
is served from the control-plane origin you already talk to — it is never a
redirect to imgproxy, so it works from any browser <img src>.
- imgproxy is internal-only — it is never exposed on the host. Only the
control plane knows
IMGPROXY_KEY/IMGPROXY_SALT, so only it can mint valid transform URLs. This prevents resize-bombing and arbitrary source fetches. - The
publicvariant serves transforms for objects in apublic=truebucket, unauthenticated. Theauthenticatedvariant requires a valid key or JWT (and honors owner-prefix RLS on the bucket).
Configuring imgproxy URL signing
This signs the imgproxy transform URLs the control plane mints — it is
unrelated to storage object signed URLs
(createSignedUrl), which use a separate per-project HMAC key.
Set both IMGPROXY_KEY and IMGPROXY_SALT in .env (they must be set together):
IMGPROXY_KEY=$(openssl rand -hex 32)IMGPROXY_SALT=$(openssl rand -hex 32)Restart the stack after changing them. If they are unset, imgproxy runs in insecure (unsigned) mode — fine for local dev, but you should set both in production so the transform surface can’t be forged.
The endpoints
| Variant | Path | Auth |
|---|---|---|
| Public | GET /v1/storage/<id>/render/image/public/<bucket>/<path> | none (bucket must be public) |
| Authenticated | GET /v1/storage/<id>/render/image/authenticated/<bucket>/<path> | key/JWT |
Query parameters
| Param | Meaning | Example |
|---|---|---|
width | target width (px) | width=400 |
height | target height (px) | height=400 |
resize | cover (default, crop to box) · contain (fit) · fill (force) | resize=contain |
quality | output quality, 1–100 | quality=80 |
format | origin · webp · avif · jpeg · png | format=webp |
These map to imgproxy processing options (rs:fill:W:H, rs:fit:W:H,
rs:force:W:H, q:N, and a format extension) under the hood.
From the SDK
Use getPublicUrl(path, { transform }) for a public bucket — it returns the
render/image/public URL with no network call:
const { data } = db.storage.from('avatars').getPublicUrl('user-123/photo.jpg', { transform: { width: 400, height: 400, resize: 'cover', format: 'webp' },})// data.publicUrl →// https://<host>/v1/storage/<project>/render/image/public/avatars/user-123/photo.jpg// ?width=400&height=400&resize=cover&format=webpThe same shape is available in every SDK (get_public_url / getPublicUrl), and
a transformed URL is just <host>/v1/storage/<id>/render/image/... — drop it
straight into an <img src> (for a public bucket) or fetch it server-side.
Why use it
- Store once, serve many — keep the original; derive every size/format on demand.
- Modern formats — emit WebP/AVIF to clients that support them, cutting bytes significantly.
- Responsive images — generate
srcsetvariants from one source. - Safety — signing prevents resize-bombing and arbitrary source fetches; the imgproxy sidecar stays off the public network.
Caching
Put a CDN or Traefik cache in front of the control plane so each variant is computed once. The transform URL is deterministic for a given source + options, which makes it cache-friendly.
An object’s uploaded cacheControl/Cache-Control directive is stored on the
object and re-emitted on download, so a CDN/Traefik cache in front of the control
plane honors it directly (see
Working with objects → cacheControl).
Next: Edge Functions → Overview.