Skip to content

Image Transformations and the CDN

Supabase Storage can resize and reprocess an image on the fly at request time using plain query parameters, backed by an imgproxy container — but this specific local stack has that container’s feature flag disabled by default, which is itself worth understanding precisely, since it is the same gate hosted Supabase uses to paywall the feature.

Instead of uploading one photo.png and separately uploading photo-thumb.png, photo-medium.png, and so on, you upload the image once and ask for whatever size you need when you serve it. Both getPublicUrl and createSignedUrl accept a transform option:

src/rt-transform-url.ts
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
const { data } = supabase.storage
.from('rt-images')
.getPublicUrl('user123/photo.png', {
transform: {
width: 100,
height: 100,
resize: 'cover',
},
});
console.log(data.publicUrl);
// http://127.0.0.1:55321/storage/v1/render/image/public/rt-images/user123/photo.png?width=100&height=100&resize=cover

width and height set the target dimensions in pixels. resize controls how the image fits those dimensions: cover (the default) fills the target size and crops any excess, contain fits the whole image inside the target size while preserving aspect ratio, and fill stretches the image to the exact dimensions without preserving aspect ratio. getPublicUrl builds this URL client-side with no network call at all — it is just string construction — so the real question is what happens when a client actually requests that URL.

Proof: this local stack has image transformation disabled — and exactly why

Section titled “Proof: this local stack has image transformation disabled — and exactly why”

A real 600×400 PNG (351,741 bytes, generated on this Mac with sips) uploaded to a real bucket, then requested through the exact URL getPublicUrl’s transform option produced above:

--- transformed: width=100&height=100&resize=cover ---
HTTP_STATUS:404
{"statusCode":"404","error":"Not Found","message":"Route GET:/render/image/public/rt-images/.../photo.png?width=100&height=100&resize=cover not found","code":"InvalidRequest"}

Not a transform failure — the /render/image/... route itself does not exist on this stack’s Kong gateway at all. Checking the storage container’s own environment confirms why:

scripts/rt-check-imgproxy-config.sh
docker exec supabase_storage_probe env | grep -i "IMAGE_TRANSFORM\|IMGPROXY"
ENABLE_IMAGE_TRANSFORMATION=false
IMAGE_TRANSFORMATION_ENABLED=false
IMGPROXY_URL=http://supabase_imgproxy_probe:5001

IMGPROXY_URL is configured (the storage-api knows where an imgproxy container would live), but ENABLE_IMAGE_TRANSFORMATION=false means storage-api never registers the /render/image route in the first place — and confirmed by docker ps -a, no supabase_imgproxy_probe container exists at all yet (it would be created lazily on first enabled use). This traces directly to the probe’s own supabase/config.toml:

# tools/probe/supabase/config.toml (excerpt — NOT part of this lesson's migration; shown for reference only)
[storage]
enabled = true
file_size_limit = "50MiB"
# [storage.image_transformation]
# enabled = true

The [storage.image_transformation] block is present but commented out — the local CLI’s own scaffolded default. This is not a bug in this course’s probe; it is the CLI’s documented default, and it mirrors hosted Supabase exactly: image transformations are a feature gated to Pro plan and above on hosted projects (supabase.com/docs/guides/storage/serving/image-transformations), so a fresh local project defaulting to “off” matches a fresh free-tier hosted project defaulting to “off” too. Enabling it locally is a one-line config.toml change (enabled = true under [storage.image_transformation]) followed by supabase stop && supabase start — not applied in this lesson because it is a shared setting across this course’s whole probe stack (other lessons’ proofs must not be disrupted by a stack restart), per this course’s own harness rules.

cache-control: what is actually provable locally, and a real surprise in it

Section titled “cache-control: what is actually provable locally, and a real surprise in it”

Independent of image transformation, every Storage object carries a real Cache-Control HTTP header, controllable at upload time via the cacheControl option — and this part works today, transform flag or not. Default (no cacheControl passed) versus an explicit value:

--- default upload, no cacheControl option ---
cache-control: no-cache
--- upload with cacheControl: '3600' ---
cache-control: 3600

Both are real response headers from the same supabase_storage_probe container. The second one is worth pausing on: the docs’ own examples pass cacheControl: '3600' as a bare numeric string (cacheControl: '86400', // Cache for 24 hours — supabase.com/docs), and that string is written into the Cache-Control header verbatim — the real response above is literally cache-control: 3600, not the HTTP-spec-correct cache-control: max-age=3600. A bare number is not a valid Cache-Control directive per HTTP semantics; whether a given CDN or browser honors it, ignores it, or falls back to a default is implementation-dependent behavior you should not rely on. If you need an unambiguous, correct header, set the full directive yourself where your stack allows it, rather than assuming cacheControl: '3600' alone produces standards-correct caching.

The CDN edge cache — Smart CDN, invalidation, and the trade-off it replaces

Section titled “The CDN edge cache — Smart CDN, invalidation, and the trade-off it replaces”

On hosted Supabase, the first request for a given file plus a given set of transform parameters does real work: Storage decodes, resizes, and returns the result; the transformed output is then cached at the CDN edge, keyed by the file and the exact transform parameters. The docs are specific about invalidation: “When Smart CDN is enabled, the asset is cached on the CDN for as long as possible… When a file is updated or deleted, the CDN cache is automatically invalidated… It can take up to 60 seconds for the CDN cache to be invalidated as the asset metadata has to propagate across all the data-centers around the globe” and “Smart CDN caching works with all types of storage operations including signed URLs” (supabase.com/docs/guides/storage/cdn/smart-cdn). This is not locally provable (there is no CDN edge in a local Docker stack — every request here goes straight to the one storage-api container), so treat the 60-second figure and the “works with signed URLs too” claim as docs-sourced facts, not something reproduced above.

The trade-off this replaces: resizing images yourself at upload time and storing photo-thumb.png, photo-medium.png, photo-large.png avoids any transform cost at read time, but costs N stored copies of every image forever, forces you to pick sizes up front, and means retroactively reprocessing everything if you need a size you did not anticipate. On-the-fly transformation (when enabled) flips that: a small amount of latency on the first request for a given size, in exchange for one stored original per image and any size at any time.

Signed vs public URLs for transformed images

Section titled “Signed vs public URLs for transformed images”

Everything above composes with the signed-URL mechanism from the previous lesson exactly as documented — a signed URL’s transform option works the same way getPublicUrl’s does, and per the Smart CDN quote above, the edge cache applies to signed-URL requests too, not just public ones. The choice between them is unrelated to transformation itself: use getPublicUrl (with or without transform) for a public bucket where anyone with the link should see the image — a product photo, a public avatar; use createSignedUrl (with or without transform) for a private bucket where access must be checked per-request — a user’s private document thumbnail, a paywalled image — accepting the fixed expiry window as the cost, exactly as covered in the storage-buckets-and-policies lesson.

flowchart LR
  original[("Original image: photo.png (351,741 bytes)")]
  original --> req{"GET /render/image/...?width=100&height=100&resize=cover"}
  req --> flag{"ENABLE_IMAGE_TRANSFORMATION?"}
  flag -->|"false (this local stack)"| notfound["404: route not registered at all"]
  flag -->|"true (hosted Pro+, or config.toml enabled)"| imgproxy["imgproxy decodes, resizes, re-encodes"]
  imgproxy --> cdn["Cached at CDN edge, keyed by file + exact params"]
One original image can serve any transform variant once ENABLE_IMAGE_TRANSFORMATION is on; this local stack has it off

An AI assistant, asked to “serve a 200x200 thumbnail of every avatar, cached properly,” wrote this:

🐛 Spot the bug

const { data } = supabase.storage
.from('avatars')
.getPublicUrl(path, {
transform: { width: 200, height: 200, resize: 'cover' },
});
await supabase.storage.from('avatars').upload(path, file, {
cacheControl: 3600,
});
return data.publicUrl;
Show the answer

Two independent bugs. First, cacheControl: 3600 — passed as a number, not the string the API expects (cacheControl: '3600') — will fail TypeScript’s type check against supabase-js’s FileOptions (cacheControl?: string) if this were caught at compile time, or silently coerce/behave unexpectedly at runtime if not. Second, and more subtly: even fixed to a string, cacheControl: '3600' does not produce a correct Cache-Control: max-age=3600 header — proved earlier in this lesson, the value is written verbatim, so the real header becomes the non-standard Cache-Control: 3600. “Cached properly” needs either accepting that ambiguity (many CDNs are lenient) or setting the header through whatever layer of the stack lets you write the full directive. Separately — not a bug in this code, but worth checking before shipping it — getPublicUrl with transform builds a URL assuming image transformation is enabled on the target project; on a fresh project (local or a free-tier hosted one) that route 404s exactly as proved above, so the “thumbnail” silently never resolves until the feature is turned on.

Image transformation is not a Storage-native feature — it is Storage delegating to a separate service, imgproxy, gated behind one feature flag that both the local CLI and hosted plans default to off. getPublicUrl/createSignedUrl with a transform option never make a network call themselves; they just build a URL with query parameters, and everything interesting happens (or does not happen, as proved above) only when a client actually requests that URL. cache-control, by contrast, has nothing to do with imgproxy at all — it is a plain HTTP response header that Storage echoes from whatever you set at upload time, which is why it works identically whether or not image transformation is enabled, and why passing it a non-standard value produces a non-standard header with no validation catching it.

  • On-the-fly transformation (when enabled) stores one original and serves any size — at the cost of a real feature flag/plan gate you must check for first, proved above as a real 404 with a clear root cause, not a silent failure to debug blindly.
  • Setting cacheControl is a one-line option with real, provable effect on the response header — at the cost of no validation on the string you pass, proved above to produce a technically non-standard header when handed a bare number instead of a full directive.
  • The Smart CDN (hosted only) removes repeat-transform cost after the first request per size — at the cost of up to ~60 seconds of stale-cache risk after an update (docs-sourced), which matters if your application overwrites images in place rather than using new paths per version.
  • “If getPublicUrl’s transform option compiles and returns a URL, the transformed image will be served.” Proved above: the URL is built with zero validation that the target project even has transformation enabled — the failure only surfaces as a real 404 when a client actually requests it.
  • “A 404 on a transform URL means the file or path is wrong.” Proved above: the exact same object served correctly at its plain (non-transform) URL — the 404 was specifically the /render/image/... route not existing, traced to ENABLE_IMAGE_TRANSFORMATION=false, not a missing file.
  • “cacheControl: '3600' produces a standard max-age=3600 Cache-Control header.” Proved above: the string is echoed verbatim into the header with no reformatting — the real response was cache-control: 3600, which is not a valid HTTP Cache-Control directive on its own.
  • “CDN caching for transformed images is something you configure.” Per the docs quoted above, Smart CDN caching is automatic once enabled — the only lever you control is the object’s own cacheControl value at upload time, not a separate CDN configuration step.
Which query/transform parameters control an on-the-fly Storage image transformation
On this course's local stack, requesting a transform URL (?width=100&height=100&resize=cover) on a real uploaded PNG returns what, and why
What does uploading with cacheControl: '3600' actually produce in the response header, proved on this stack
What is the tradeoff of on-the-fly transformation versus pre-generating multiple image sizes yourself
Per the Smart CDN docs quoted in this lesson, does the edge cache apply to signed URLs or only public ones