Serving from a CDN
For large catalogs, keep the rendered variants in a bucket and put a CDN in front of it, so that a thumbnail that already exists never reaches PHP. The application only handles misses, and still renders them on the fly:
- Browserimg.example.com/image/glide/…
- CDNedge cache
- BucketS3, R2, GCS…
- Browser
- CDN
- Bucketno such key
- Symfony/image/… renders the variant
- Bucketstores it for the next hit
picasso: cache_control: error_max_age: 60 # let the CDN absorb repeated 404s for a minute transformers: glide: sign_key: '%env(PICASSO_SIGN_KEY)%' cache: 'thumbs.storage' # Flysystem storage of the bucket base_url: 'https://img.example.com' # the CDN host defer_cache_write: true # upload a miss after the response is sent public_cache: enabled: true prefix: 'image' # the URL path before the transformer name (/image/glide/…)base_urlmakes every generated image URL point at the CDN:https://img.example.com/image/glide/….public_cache.prefixmakes the cache key equal the URL path: the variant served at/image/glide/flysystem/photo.jpg/fm_webp%2Cw_640.webpis stored under the keyimage/glide/flysystem/photo.jpg/fm_webp,w_640.webp, which is exactly what the CDN looks up in the bucket. URL aliases appear in both alike. Set it to what comes before the transformer name in the URL path:imagewith the default routes, or e.g.media/imagewhen they are imported with a/mediaprefix.- On a miss, the application renders the variant, stores it in the bucket and returns it with
Cache-Control: public, max-age=31536000, immutable(thecache_controldefaults). The next request is a hit. defer_cache_writekeeps the upload out of the client’s wait: a miss is rendered to a local temporary directory, answered from there, and moved to the bucket onkernel.terminate, after the client has been released (fastcgi_finish_request()under PHP-FPM and FrankenPHP, after the request in FrankenPHP worker mode). The directory is only created by a miss and deleted once its renders are uploaded, so only the variants of requests in flight are on local disk. A failed upload is logged, not thrown: the next request renders the variant again. It is awarningwhen the storage is unavailable (unreachable,5xx,429), anerrorotherwise. The upload still occupies the PHP worker until it completes, so size the worker pool for bursts of misses. The upload runs within the request’smax_execution_time(it is not reset onkernel.terminate), so give your storage client a timeout that leaves room for it: a stalled upload would otherwise end in a fatal error after the response was sent.cache_control.error_max_agemakes the image controller’s 404s cacheable (Cache-Control: public, max-age=…), so a CDN does not send every request for a missing image to the application. Without it, 404s stay uncacheable.
The signature is only checked on a miss: that is all it needs to protect, since it guards the rendering, and a variant that already exists is public anyway.
On the CDN side:
- Use the bucket as the origin, and fall back to the application on
403/404(CloudFront origin groups, a Cloudflare Worker reading R2, Fastly, or a reverse proxy such as nginx withproxy_intercept_errorsanderror_page 403 404 = @app). S3 answers403for a missing key when the reader cannot list the bucket. - Leave the query string (
s) out of the cache key, but forward it to the application on a miss: it carries the signature. - Give hits served from the bucket a long lifetime in the CDN’s cache policy (or response headers policy): the bundle does not set
Cache-Controlon the objects it stores.
A variant URL never changes meaning, so its cache never needs revalidating. Changing the source file behind an unchanged path therefore needs a purge, which clears the bucket but not the CDN’s edge caches. Uploads with unique file names (as VichUploaderBundle generates) never need either.
