CDN Integration
Pimcore can integrate with a CDN that supports tag-based purging. The bundled
implementation targets Fastly, and the architecture
allows other providers to be added by implementing
Pimcore\Cdn\PurgeClientInterface.
This document describes the cache-proxy integration: the CDN sits in front of Pimcore, caches asset and thumbnail responses, and is invalidated when assets or thumbnail configurations change. It also covers the opt-in image-optimizer mode (Fastly Image Optimizer), which transforms originals at the edge instead of generating thumbnail files — see Image Optimizer Mode.
Quick Start
Set these environment variables on your runtime:
| Variable | Required | Example | Purpose |
|---|---|---|---|
CDN_PROVIDER | yes | fastly | Activates the CDN listeners. Empty/unset disables all CDN behavior — Pimcore will not emit cache tags or dispatch purges. |
FASTLY_API_TOKEN | for Fastly | xxxxxxxx | Fastly API token with the purge_select scope (least privilege — sufficient for surrogate-key and URL purges; purge_all is not required). |
FASTLY_API_SERVICE | for Fastly | abc123… | Fastly service ID. |
FASTLY_API_BASE_URL | no | https://api.fastly.com (default) | Override the Fastly API base URL if your environment needs a custom API endpoint/proxy. |
CDN_BASE_URL | no | https://cdn.example.com | Public base URL of the CDN. Required when original assets are served statically and must be purged by URL — see Original Assets. |
CDN_IMAGE_OPTIMIZER | no | fastly | Enables CDN-side image transformation (Fastly Image Optimizer). Empty = Pimcore generates thumbnails as usual. Independent of CDN_PROVIDER. Requires CDN_BASE_URL. |
Once CDN_PROVIDER is set, three event listeners activate:
CdnSurrogateKeyListener— emitsSurrogate-KeyandCache-Tagheaders on thumbnail responsesCdnAssetCookieStripperListener— stripsSet-Cookiefrom asset and thumbnail responses (priority-200, runs after personalization listeners)CdnPurgeListener— dispatches purge messages on asset and thumbnail-config changes
When CDN_PROVIDER is empty, all three listeners short-circuit and the
application behaves exactly as it did before the integration was introduced.
How Tagging Works
For each thumbnail response, CdnSurrogateKeyListener emits the following
tags (via both Surrogate-Key for Fastly and Cache-Tag for clients/proxies
that prefer the standard header):
asset-{id}— invalidate every cached representation of this asset (every thumbnail variant of asset{id})thumb-{configName}— invalidate every thumbnail using this config, across all assetsasset-{id}-thumb-{configName}— invalidate this specific variant only
For original asset URLs (/var/assets/...) the same listener also computes:
asset-path-{16-char-xxh3-hash}— the path-hash for tag-purging the original. The hash isxxh3('/var/assets' + asset_full_path)(a fast non-cryptographic 64-bit fingerprint; a collision merely over-purges, which is harmless).
…but this only reaches the cached object if /var/assets/... responses pass
through PHP. See Original Assets.
Image Optimizer Mode (opt-in)
When CDN_IMAGE_OPTIMIZER=fastly, Pimcore stops generating thumbnail files for eligible image
variants and instead returns a Fastly Image Optimizer URL that transforms the original asset
at the edge. The customer API is unchanged — $asset->getThumbnail('cfg')->getPath() returns the
CDN URL transparently. CDN_PROVIDER (purge) and CDN_IMAGE_OPTIMIZER (transform) are independent
switches.
Eligibility. A thumbnail is rewritten only when both hold; otherwise Pimcore generates it as usual:
-
The asset's source MIME type is in the configured allowlist
pimcore.cdn.image_optimizer_source_formats(default:image/jpeg,image/png,image/gif,image/webp— the source formats Fastly Image Optimizer can ingest). Vector graphics (SVG), video thumbnails, and document previews are never rewritten. Raster formats the optimizer cannot ingest (e.g. TIFF, PSD, BMP) also fall back to Pimcore generation — without this guard they would be rewritten to a transform URL the edge cannot process (the original served untransformed, or a 404), so the fallback is what keeps those thumbnails working. -
The thumbnail config uses only translatable transforms:
resize,scaleByWidth,scaleByHeight,contain,cover,crop, plus format/quality and 2x high-resolution. Note:coveris translatable only with the defaultcenterpositioning. Any non-center positioning (topleft,topright, …) falls back, as does anycoverapplied to an asset that has a focal point set — Pimcore turns those into focal-point crops the edge cannot reproduce.- High-resolution is translatable only at exactly 2x. Other factors (1.5x, 3x, 4x, …) fall back, since the transform carries only an integer 2x device-pixel-ratio.
A config using anything else (
frame,rotate,mirror,trim,roundCorners, color adjustments, non-center/focal-pointcover, non-2x high-resolution,cropPercent) falls back to Pimcore generation.
The source-format allowlist is configurable — extend it only with formats your optimizer can actually ingest:
pimcore:
cdn:
image_optimizer_source_formats:
- 'image/jpeg'
- 'image/png'
- 'image/gif'
- 'image/webp'
MIME types are matched case-insensitively. Setting an empty list disables rewriting entirely (every thumbnail is then generated by Pimcore).
CDN_BASE_URL is required in optimizer mode (used to build absolute transformation URLs);
the Fastly adapter throws if it is missing.
Purging transforms. With static originals (Mode B, the default — see
Original Assets) edge transforms carry no inherited Surrogate-Key, so they are
not reachable by tag purges (asset-{id}, asset-path-{hash}, thumb-{config}). They are
invalidated by URL purge of the original instead: CdnPurgeListener dispatches a
PurgeCdnUrlMessage on asset update/delete (when CDN_BASE_URL is set), and Fastly cascades that
source-URL purge to all of the original's transforms. So editing an asset does refresh its
transforms automatically. Without CDN_BASE_URL set — or for changes that do not re-save the
asset — transforms refresh only when their TTL expires.
Tag-based purge of transforms (an asset-path-{hash} purge invalidating the original and all its
transforms at once) requires the original /var/assets/... response to carry the header, which
in turn requires originals to be served through PHP (Mode A). Mode A is not provided out of
the box — Pimcore has no route that serves original assets through PHP — so it is an optional,
custom add-on (see Original Assets). For most projects TTL refresh plus
URL-purge is sufficient.
Thumbnail-config edits. Edge transforms are keyed by the original's surrogate keys, not by a
named config, so editing a thumbnail config does not invalidate already-cached transforms via
thumb-{config}. In practice a config change that alters the transform parameters (size, quality,
fit) also changes the emitted transform URL, so freshly rendered pages request a new edge object and
the stale one is simply orphaned until its TTL. To force an existing transform to refresh
immediately, re-save the affected asset: CdnPurgeListener then dispatches the URL purge that
reaches the static original and — because Fastly links each transform to its source URL — its
transforms too. Note that pimcore:cdn:purge --asset <id> is tag-only and does not reach
statically-served (Mode B) originals or their transforms (see Manual Purges); it
only invalidates PHP-served thumbnails.
Cookie Stripping
Pimcore's personalization bundle sets cookies (_pc_tss, _pc_tvs) on every
response, including assets. Most CDNs refuse to cache responses that carry a
Set-Cookie header. CdnAssetCookieStripperListener runs at priority -200
(after the targeting listener at -115) and removes the personalization cookies (_pc_tss, _pc_tvs) from
- Thumbnails:
(?:^|/)(image|video)-thumb__\d+__([a-zA-Z0-9_\-]+)/ - Originals: the configured original-asset prefix (defaults to
^/var/assets/; see “The URL prefix contract”)
When CDN_PROVIDER is empty, this listener is inert and cookies pass through
unchanged.
Purge Mechanism
CdnPurgeListener subscribes to six events:
| Event | Action |
|---|---|
AssetEvents::POST_UPDATE | Dispatch asset-{id} and asset-path-{newPathHash}. If renamed/moved, also asset-path-{oldPathHash}. |
AssetEvents::POST_DELETE | Same as above. |
ImageThumbnailConfigEvents::POST_UPDATE | Dispatch thumb-{configName} (global, across all assets). |
ImageThumbnailConfigEvents::POST_DELETE | Same as above. |
VideoThumbnailConfigEvents::POST_UPDATE | Dispatch thumb-{configName} (global, across all assets). |
VideoThumbnailConfigEvents::POST_DELETE | Same as above. |
All tags of one event are dispatched as a single PurgeCdnTagsMessage (and,
if CDN_BASE_URL is set, per-URL PurgeCdnUrlMessages) onto the
pimcore_cdn_purge Symfony Messenger transport. A worker consumes the queue and calls the configured
PurgeClientInterface implementation (currently FastlyPurgeClient).
Asset paths with special characters Asset filenames may legitimately contain spaces or non-ASCII characters (e.g.
/Car Images/Mötley.jpg). The listener percent-encodes each path segment when building thePurgeCdnUrlMessageURL, so the URL sent to the CDN matches the cache key the CDN actually stored for the browser-encoded request. Tag-based purges hash the decoded path on both sides and are unaffected.
Worker Configuration
Add pimcore_cdn_purge to your messenger:consume worker setup:
bin/console messenger:consume pimcore_cdn_purge --memory-limit=250M --time-limit=3600
For inspecting failed purges, also consume the failure transport:
bin/console messenger:consume pimcore_cdn_purge_failed --memory-limit=128M --time-limit=3600
bin/console messenger:failed:show
Retry Behavior
The pimcore_cdn_purge transport ships with the following retry policy in
bundles/CoreBundle/config/pimcore/default.yaml:
max_retries: 5- Exponential backoff: 2 s → 4 s → 8 s → 16 s → 32 s, capped at 60 s
- Failure transport:
pimcore_cdn_purge_failed
A non-2xx HTTP response from Fastly causes FastlyPurgeClient to throw,
which lets Messenger retry the message. Network/transport failures (HTTP
client exceptions) also retry. After exhausting retries, the message lands in
pimcore_cdn_purge_failed for inspection or manual replay.
Manual Purges
Use the pimcore:cdn:purge console command for recovery scenarios, deploy
hooks, content-migration scripts, or runbooks. Routine purges happen
automatically via the listener — you should not normally need this command.
# Purge all caches related to a specific asset (asset-{id} + asset-path-{hash})
bin/console pimcore:cdn:purge --asset 42
# Purge multiple assets in one call
bin/console pimcore:cdn:purge --asset 42 --asset 43
# Purge every thumbnail using a given config (thumb-{configName})
bin/console pimcore:cdn:purge --config product_detail
# Combine
bin/console pimcore:cdn:purge --asset 42 --config product_detail
Notes
- The command computes the
asset-path-{hash}tag itself, so unknown asset IDs are reported and skipped (only theasset-{id}thumbnail tag is purged in that case).- Tags are deduplicated before being submitted (Fastly limits each batch purge to 256 keys).
- The command calls
PurgeClientInterface::purgeByTags()directly and does not go through the messenger queue, so retries do not apply.- It issues tag purges only — it never URL-purges. In Mode B (statically-served originals, the default) the original and its Fastly IO transforms carry no surrogate tags, so
--assetdoes not invalidate them; it only purges the asset's PHP-served thumbnails. To refresh a Mode B original or its transforms on demand, re-save the asset (which dispatches aPurgeCdnUrlMessage) or URL-purge via your CDN's API.- "Purge everything" is intentionally not supported. Use your CDN provider's admin panel or API for that operation.
CDN Edge / VCL Requirements
The CDN edge must be configured to:
-
Strip the
Cookierequest header before lookup for asset paths, otherwise per-user cookies fragment the cache key and the cache hit rate collapses. A typical Fastly VCL snippet:sub vcl_recv {
if (req.url ~ "^/(image|video)-thumb__" ||
req.url ~ "^/var/assets/" ||
req.url ~ "\.(jpg|jpeg|png|gif|webp|svg|ico|css|js|mp4|webm|woff2?|ttf|eot)(\?|$)") {
unset req.http.Cookie;
}
} -
Honor
Surrogate-Keyas the cache-tag header. Fastly does this natively and strips the header from the downstream response. Other providers may needCache-Taginstead — both headers are emitted, so the edge can use whichever it prefers. -
Accept
PURGErequests against asset URLs forPurgeCdnUrlMessage, and the Fastly batch-purge endpointPOST /service/{service_id}/purge(withSurrogate-Keyheader) forPurgeCdnTagsMessage. Both are handled byFastlyPurgeClient.
Original Assets
For original assets (/var/assets/...) there are two operation modes.
The URL prefix contract (read this first)
The CDN integration must build the same public URLs your templates emit —
otherwise purge URLs and asset-path-{hash} tags target paths the CDN never
cached, and stale originals silently survive every purge.
Pimcore emits original-asset URLs as {frontend_prefixes.source}{asset path}
(see Asset::getFrontendFullPath()), and the CDN integration follows the same
setting:
pimcore.assets.frontend_prefixes.sourceset (e.g./var/assets): the integration uses it for purge URLs, optimizer URLs, response matching, and tag hashing. For an absolute-URL prefix only its path component is used for matching/tagging — the host part must be yourCDN_BASE_URLhost.- unset/empty (Pimcore default): the integration assumes the
/var/assetscontract described under Mode B. Note that stock Pimcore web-server configs serve originals at the prefix-less tree path via an internal rewrite — under that setup the CDN caches URLs the integration cannot purge. To use the CDN integration for originals, set the prefix explicitly so emitted URLs and the CDN contract agree:
pimcore:
assets:
frontend_prefixes:
source: /var/assets
With that setting templates emit /var/assets/... URLs, the web server serves
them statically off disk (Mode B), and purge URLs match the cached objects.
Mode B: URL purge for originals (static serving) — default
This is the default and recommended mode: keep /var/assets as static-file
serving so the web server streams the file directly off disk (fast, with native
range and conditional-request support).
Original responses do not carry CDN tags in this mode (the PHP kernel never runs
for a statically-served file). Configure CDN_BASE_URL so CdnPurgeListener
dispatches PurgeCdnUrlMessage on asset update/delete (including the old URL on
rename/move); Fastly then issues a direct PURGE https://cdn.example.com/var/assets/...
by URL. If CDN_BASE_URL is not set, originals refresh only when TTL expires.
Do not route
/var/assets/*through/index.phpon Upsun/Platform.sh expecting Mode A — Pimcore has no route that serves original assets through PHP, so every original would return 404. See Mode A below.
Mode A: Tag-based purge for originals (optional, custom — not shipped)
Use this only if you specifically need asset-path-{hash} surrogate tags to
invalidate cached originals (and, by inheritance, their Fastly IO transforms)
instead of relying on URL-purge / TTL.
Requirement: original asset requests must be handled by PHP on cache fill, not
served directly as static files — otherwise CdnSurrogateKeyListener cannot emit
Surrogate-Key/Cache-Tag. Pimcore does not provide a controller for this;
only thumbnails have a PHP serving route. To enable Mode A you must add your own
controller/route that streams originals from the asset storage (mirroring
PublicServicesController::thumbnailAction) so the response is produced by PHP,
then point /var/assets routing at /index.php.
Be aware this re-implements static file serving in PHP: you are responsible for
HTTP range (206) and conditional (304) handling, and you accept the per-request
kernel overhead on originals (mitigated, but not eliminated, by edge caching).
Private / Access-Controlled Assets
The CDN listeners never emit cache tags (and never strip cookies) for a response when:
- the response carries
Cache-Control: no-store(set this on gated/access-controlled asset controllers to keep them off the CDN), or - the request path matches a pattern in
pimcore.cdn.excluded_paths.
Query-string variants of asset URLs (e.g. cache-busters like ?v=3) are tagged and
cookie-stripped like their bare-path equivalents: the CDN caches them under the full URL
anyway, so the shared path-derived tags are what let a purge reach every cached variant.
Signed/gated URLs should opt out via no-store or excluded_paths.
Note: only
no-storeis honored as an opt-out.privateandno-cacheare not treated as opt-outs because Symfony emits a defaultCache-Control: no-cache, privateon responses that set no explicit cache headers, making them indistinguishable from a deliberate choice. Useno-store.
Configure path exclusions in YAML:
pimcore:
cdn:
excluded_paths:
- '#^/var/assets/private/#'
Adding a New CDN Provider
Implement Pimcore\Cdn\PurgeClientInterface:
<?php
namespace App\Cdn;
use Pimcore\Cdn\PurgeClientInterface;
final class MyCdnPurgeClient implements PurgeClientInterface
{
public function purgeByTag(string $tag): void { /* ... */ }
/** @param list<string> $tags */
public function purgeByTags(array $tags): void { /* ... */ }
public function purgeByUrl(string $url): void { /* ... */ }
}
Register it as a service and tag it with pimcore.cdn.purge_client, setting the
provider attribute to the identifier you will use in CDN_PROVIDER:
services:
App\Cdn\MyCdnPurgeClient:
tags:
- { name: pimcore.cdn.purge_client, provider: mycdn }
CdnPurgeClientRegistry collects every tagged client and lazily resolves the one
whose provider matches CDN_PROVIDER at runtime. You do not replace the
Pimcore\Cdn\PurgeClientInterface alias — it points at the registry, which does
the selection. The Fastly implementation in bundles/CoreBundle/config/cdn.yaml
(tagged via the #[AutoconfigureTag] attribute on FastlyPurgeClient) is a
reference template.
Multi-provider selection is controlled by
CDN_PROVIDERand thepimcore.cdn.purge_clientservice tags (the registry resolves the selected provider lazily).
Verification Guidance
For deployment verification, test against your target CDN endpoint and check:
Cache-Tag/Surrogate-Keypresence on 2xx thumbnail responses.- Expected behavior for originals based on your selected mode in Original Assets.
- Messenger queue health for
pimcore_cdn_purgeandpimcore_cdn_purge_failed.