Architecture#
plone.pgthumbor replaces Plone’s built-in image scaling pipeline with Thumbor, an
open-source image processing server.
Instead of loading blob data into Python,
resizing with Pillow, and storing the result back in ZODB, Plone generates a signed
Thumbor URL and sends the browser a 302 redirect.
Thumbor fetches the blob
directly from PostgreSQL (via zodb-pgjsonb’s blob_state table), scales it, and
serves the result – all without Plone touching a single pixel while serving the
request.
Plone does decode pixels in one place, and only there: when an image is
uploaded or edited, it builds the capped source derivative Thumbor will read
from.
See Source derivatives below.
This page explains how data flows through the system, how the components fit together, and the reasoning behind the key design choices.
Key files#
plone.pgthumbor (Plone side)#
File |
Purpose |
|---|---|
|
|
|
|
|
Source derivative pixel work – decode, colour normalisation, encode; pure bytes in, bytes out |
|
|
|
|
|
Request context for command line entry points, so a re-index cannot blank |
|
|
|
|
|
|
|
|
|
|
|
|
|
Conditional adapters for third-party addons (for example, |
|
ZCML overrides that wire |
|
Service registration, GenericSetup profile, control panel page, conditional addon includes |
zodb-pgjsonb-thumborblobloader (Thumbor side)#
File |
Purpose |
|---|---|
|
Thumbor |
|
|
|
|
|
|
|
S3 download via boto3 + |
Overview#
The fundamental idea is separation of concerns: Plone decides what to show and who may see it. Thumbor handles the how – fetching, resizing, caching, and serving image bytes. The two communicate indirectly through signed URLs and a shared PostgreSQL database.
flowchart LR
A[Plone] -->|302 redirect with signed URL| B[Browser]
B -->|follow redirect| C[Thumbor]
C -->|fetch blob| D[(PostgreSQL)]
C -->|fallback| E[(S3)]
C -->|serve scaled image| B
Request flow#
A complete image request touches multiple services. Here is the sequence for a browser rendering a page that contains a scaled image:
sequenceDiagram
participant B as Browser
participant N as nginx
participant P as Plone
participant T as Thumbor
participant PG as PostgreSQL
B->>N: GET /page (HTML)
N->>P: proxy /page
P->>B: HTML with <img src="/thumbor/{hmac}/.../{zoid}/{tid}">
B->>N: GET /thumbor/{hmac}/.../{zoid}/{tid}
N->>T: strip prefix, forward to Thumbor
T->>T: verify HMAC signature
T->>PG: SELECT data FROM blob_state WHERE zoid=? AND tid=?
PG->>T: blob bytes
T->>T: resize / crop / convert
T->>N: scaled image bytes
N->>B: image response (cacheable)
Authenticated content (3-segment URL)#
When the content is not publicly accessible, Plone appends the content object’s
ZOID as a third URL segment.
Thumbor’s AuthImagingHandler detects the 3-segment
format and makes a subrequest to Plone before loading the blob:
sequenceDiagram
participant B as Browser
participant N as nginx
participant T as Thumbor
participant P as Plone (internal)
participant PG as PostgreSQL
B->>N: GET /thumbor/{hmac}/.../{blob_zoid}/{tid}/{content_zoid}
N->>T: strip prefix, forward (Cookie header preserved)
T->>T: detect 3-segment URL → extract content_zoid
T->>P: GET /@thumbor-auth?zoid={content_zoid} (Cookie + Authorization forwarded)
P->>PG: SELECT (allowed_roles && user_principals) FROM object_state
PG->>P: allowed = true/false
P->>T: 200 OK / 401 Unauthorized
alt authorized
T->>PG: SELECT data FROM blob_state
PG->>T: blob bytes
T->>B: scaled image
else unauthorized
T->>B: 403 Forbidden
end
Step by step (Plone side)#
Browser requests a page. Plone renders HTML. When
@@imagesis called for an image field,ThumborImageScalingcreates aThumborImageScaleinstance.ThumborImageScale.__init__()generates the URL. It extracts the blob’s ZOID and TID fromNamedBlobImage._blob(viaget_blob_ids()), maps the Plone scale mode to Thumbor parameters, and callsthumbor_url()to produce an HMAC-signed URL.Access check decides 2-segment vs 3-segment URL.
_needs_auth_url()queries PostgreSQL directly: does the object’sallowed_rolesTEXT[] column contain'Anonymous'? If yes, the content is public and a 2-segment URL suffices. If no (or if paranoid mode is enabled), the content object’s ZOID is appended as a third segment.index_html()returns a 302 redirect. The browser follows the redirect to Thumbor.
Step by step (Thumbor side)#
Thumbor receives the request. If
HANDLER_LISTSincludeszodb_pgjsonb_thumborblobloader.auth_handler,AuthImagingHandlerintercepts the request before the standard imaging pipeline.Auth check (3-segment URLs only).
_extract_content_zoid()inspects the URL path. If the last three segments are all valid hex, this is a 3-segment authenticated URL. The handler forwards the browser’sCookieandAuthorizationheaders to Plone’s@thumbor-authendpoint for verification. Results are cached per(content_zoid, cookie)forPGTHUMBOR_AUTH_CACHE_TTLseconds.The loader fetches blob data.
loader.load()parses the image path into(zoid, tid)integers, checks the disk cache, then queriesblob_statein PostgreSQL. If the row has adatacolumn (PG bytea), the bytes are returned directly. If only ans3_keyis present, the loader downloads from S3 viaasyncio.to_thread(since boto3 is synchronous).Thumbor processes the image. Standard Thumbor pipeline: decode, apply operations (resize, fit-in, smart crop, filters), encode, return.
Result caching. Thumbor’s built-in result storage caches the processed image. Subsequent requests for the same signed URL skip processing entirely.
Design choices#
Why 302 redirect (not proxy)#
Plone could proxy the request – fetching the scaled image from Thumbor and streaming it to the browser. This would hide Thumbor behind Plone but at a severe cost:
Memory. Every image response would flow through the Plone WSGI process, consuming Python memory for the duration of the transfer.
Concurrency. Each proxied image request occupies a Plone worker thread. A page with 20 images would hold 20 threads during image delivery.
Cacheability. A 302 redirect lets the browser (and any CDN or reverse proxy in front) cache the Thumbor URL directly. Subsequent requests never touch Plone.
The 302 approach means the browser makes two requests for the first load – one to Plone (fast, returns only a redirect header) and one to Thumbor (which does the actual work). On subsequent loads, the browser cache or CDN handles the image directly.
Why ZOID + TID as URL path#
Thumbor’s image URL is {blob_zoid:x}/{blob_tid:x} in hexadecimal.
This design
has several advantages:
Immutable cache keys. A ZODB TID (transaction ID) is assigned once and never reused. The combination of
(zoid, tid)uniquely and permanently identifies a specific version of a blob. When the image changes, it gets a new TID, producing a new URL. Old cached responses become naturally unreachable – no explicit cache invalidation needed.No path encoding. ZODB OIDs are 8-byte integers. Their hex representation contains only
[0-9a-f]characters – no URL encoding issues, no filesystem special characters, no ambiguity. Compare this with using the Plone content path, which would need encoding for spaces, Unicode, slashes, and the many edge cases of Plone’s virtual hosting.Direct database lookup. The loader queries
blob_state WHERE zoid = ? AND tid = ?– a primary key lookup, the fastest possible database operation.No ZODB dependency. Thumbor does not need ZODB, Plone, or any Zope library. It needs only psycopg and the PostgreSQL DSN. This keeps the Thumbor container small and fast.
Why ThumborScaleStorage stores no image data#
In standard Plone, AnnotationStorage stores scaled image data as annotations on
the content object.
Each scale is a persistent object containing the resized bytes.
ThumborScaleStorage overrides this entirely:
scale()delegates topre_scale(), which computes target dimensions but generates no image data.get_or_generate()returns stored metadata without callinggenerate_scale().generate_scale()also delegates topre_scale().
This means no scaling happens on the request path and no annotation objects
are created in ZODB.
ThumborScaleStorage never looks up IImageScaleFactory,
so no image bytes are decoded while a page is being served.
Pillow is imported
in the process, and the package uses it, but only on write: see
Source derivatives.
Dimension metadata (uid, width, height) is held in a
volatile, in-memory dict that lives only for the lifetime of the adapter
instance – nothing is persisted to ZODB, so no ScalesDict write transactions
are created.
This is enough for Plone to generate <img> tags with correct
width and height attributes, and enough for the catalog to index scale
information.
ZCML overrides#
plone.pgthumbor replaces two Plone components via overrides.zcml:
@@imagesbrowser page –ThumborImageScalingreplacesplone.namedfile’sImageScalingfor allIImageScaleTraversableobjects. This intercepts every image scale request site-wide. Bound toIPlonePgthumborLayer, so it only activates in Plone sites where the add-on is installed.IImageScaleStorageadapter – A factory function (thumbor_scale_storage_factory) is registered for(IImageScaleTraversable, *). The*discriminator is necessary becauseplone.namedfilecallsgetMultiAdapter((context, modified_callable), IImageScaleStorage)– the second argument is a callable orNone, never a request, so a browser-layer discriminator would never match. The factory checksIPlonePgthumborLayeron the current request at runtime: if the layer is active, it returns aThumborScaleStorageinstance (volatile dict, no Pillow); otherwise it falls back to the standardAnnotationStorage. This makes the override safe for Zope instances hosting multiple Plone sites where only some have pgthumbor installed.
These are overrides.zcml registrations (not configure.zcml), which means they
take precedence over plone.namedfile’s own registrations regardless of ZCML loading
order.
Pluggable crop providers (ICropProvider)#
Thumbor supports explicit crop coordinates in its URL format
({left}x{top}:{right}x{bottom}), which crop the source image before
resizing.
plone.pgthumbor exposes this through the ICropProvider ZCA adapter
interface, keeping addon-specific logic out of the core scaling code.
The lookup flow in _get_crop():
Call
queryAdapter(context, ICropProvider). If no adapter is registered, returnNone(no crop).Extract the scale name from plone.namedfile’s
scale_info["key"]tuple.Call
provider.get_crop(fieldname, scale_name).If the provider returns a 4-tuple
(left, top, right, bottom), convert it to the nested format((left, top), (right, bottom))that libthumbor expects.
When a crop is active, _build_thumbor_url() forces fit_in=True and
smart=False.
The rationale: if the editor has explicitly chosen a crop
region, automatic smart detection should not override that choice.
Why an adapter, not a hook or event? The ZCA adapter pattern is the right choice because:
It is conditional by nature – no adapter registered means zero overhead.
It composes cleanly with ZCML conditions (
zcml:condition="installed ...") for automatic activation when a compatible addon is present.Multiple crop sources can coexist: a more specific
forinterface wins overfor="*", following standard ZCA precedence.Third-party packages can provide their own
ICropProviderwithout modifying plone.pgthumbor code.
The built-in ImageCroppingCropProvider (in addons_compat/imagecropping.py)
reads from IAnnotations(context)["plone.app.imagecropping"], where
plone.app.imagecropping stores its crop boxes.
It is registered via
conditional ZCML and has zero import cost when the addon is not installed.
SVG passthrough#
SVGs are vector images that Thumbor cannot process.
When ThumborImageScale
detects content_type == "image/svg+xml", it falls back to the standard Plone
behavior – serving the SVG directly without redirect.
The set of skipped types
is defined in _SKIP_THUMBOR_TYPES.
Source derivatives#
Thumbor refuses to process images beyond its MAX_PIXELS limit, 75 megapixels by
default, and it answers HTTP 400 after several seconds of work when it meets one.
Press originals routinely exceed that: 100 by 75 cm at 300 dpi is 104 megapixels,
and the largest example that motivated this work was 164.6 megapixels.
Shrinking the originals destroys the asset the press area exists to serve, and
raising MAX_PIXELS leaves Thumbor decoding 100+ megapixel images on every cache
miss.
A second problem hides behind the first: Thumbor fetches the whole original
for every cache miss, including a 60 by 60 listing thumbnail, so a 40 MB blob
crosses the network and the decoder to produce a 3 KB tile.
A source derivative solves both.
It is a capped, sRGB-normalised second image that Thumbor reads instead of the
original.
The original is never modified and @@download still serves it byte for byte.
Where it lives#
The derivative is a second NamedBlobImage, stored as an attribute on the
original field value:
content.image -> NamedBlobImage (original, print resolution)
content.image._pgthumbor_source -> NamedBlobImage (derivative, capped and normalised)
Because it is a distinct NamedBlobImage, it carries its own blob and therefore
its own (zoid, tid) pair, which is exactly what a Thumbor URL addresses.
Two attributes are written on the field value:
Attribute |
Meaning |
|---|---|
|
The derivative, or |
|
|
A third attribute, _pgthumbor_is_source, is set on the derivative itself and
never on an original.
A derivative is a NamedBlobImage like any other, so it lands in the database as
a row of its own and would otherwise look like a fresh candidate to the backfill.
A marker says so by structure; matching on the generated filename instead would
silently skip an editorial upload that happened to be named the same way.
The outcome record exists because a silent skip looks exactly like an image that
correctly needs nothing, which would put the termination criterion of the backfill
out of reach and leave failures impossible to enumerate.
Reasons split into terminal ones, where re-running would change nothing, and
non-terminal ones, retry from a busy decode slot and error from a failed
decode, which an ordinary backfill run has to pick up again.
Recording max_edge is what turns tuning the cap into a setting change rather
than a migration, and recording source_ids closes an invalidation hole
described below.
Why the field value and not an annotation#
Invalidation becomes structural.
Replacing an image produces a new NamedBlobImage, which carries no derivative
attribute at all, so a fresh derivative is generated and a stale one cannot
survive.
An annotation keyed by field name would need explicit invalidation, and a missed
invalidation means serving the wrong image indefinitely.
Two lesser benefits follow: no field name plumbing, because the single URL funnel receives the field value rather than its name, and uniform coverage of every image field, whether it comes from the content type, a behaviour, or a custom schema.
The cost is an undeclared attribute on a plone.namedfile class.
NamedBlobFile is a plain Persistent subclass without __slots__, and this
package is its only writer.
One hole remains, and the outcome record closes it.
NamedBlobImage.data is a settable property, and migration scripts and bulk
import pipelines do assign to it, which replaces the bytes without replacing the
object.
The recorded source_ids are therefore compared against the original’s current
blob ids when a URL is built: a mismatch means the original was mutated in place,
and the derivative is treated as absent rather than served.
Recorded ids of None mean that no comparison is possible, not that one failed:
a derivative generated before its original was ever committed has nothing to
record.
The versioning caveat#
Structural invalidation has one exception, and it needed its own module.
Products.CMFEditions snapshots content by deep-pickling it, and
plone.app.versioningbehavior’s CloneNamedFileBlobs protects blobs from that
pickle by collecting them.
It walks top-level field values only, returning field_value._blob per image
field.
A derivative lives one level deeper, so its blob is not in that mapping: it goes
through the pickle, and ZODB.blob.Blob.__getstate__ returns None.
The result is not an error, which is what makes it dangerous.
The snapshot holds a NamedBlobImage that looks entirely valid and reads back
zero bytes.
After a revert it resolves to a real (zoid, tid) naming an empty blob, Thumbor
is handed nothing and answers 400, and the argument above does not save us,
because the attribute is present.
SkipThumborSourceDerivatives, an ICloneModifier, drops both attributes on
clone, so a reverted object arrives with a bare field value and regenerates on its
next modification.
Both attributes go, not only the derivative: a reverted object carrying a terminal
outcome record and no derivative would answer “nothing to do” forever.
Registering it is not a ZCML line.
CMFEditions clone modifiers live in the persistent portal_modifier tool, so
registration is a GenericSetup step, and it is an upgrade step rather than an
install-only handler, because an already-installed site is precisely the one this
feature exists to repair.
It degrades quietly twice over: Products.CMFEditions is not a dependency of this
package, and its portal_modifier tool exists only once the CMFEditions profile
has been applied.
Neither absence is a failure, because with no version repository there are no
snapshots to protect.
The loader is not involved#
A Thumbor URL names one blob by (zoid, tid).
Pointing it at the derivative’s blob rather than the original’s is therefore
entirely a Plone-side decision: no mapping lookup per request, no loader release,
no new Thumbor image tag.
zodb-pgjsonb-thumborblobloader does not need to know that derivatives exist.
Selection happens in _build_thumbor_url, the package’s single URL funnel, which
all four URL-emitting call sites go through.
Three things live there together, deliberately:
Selection. Prefer the derivative, unless the recorded blob ids say the original was mutated underneath it.
Crop translation.
plone.app.imagecroppingstores boxes in the original’s pixels, so against a 4000 pixel derivative of an 11811 pixel original an untranslated box cuts into empty space. Each axis gets its own factor, taken from the derivative’s actual size rather than from the cap, and rounding is direction-aware so the mapped region covers the original selection instead of cutting into the subject.Clamping. Requested width and height are capped at the selected source’s dimensions. Thumbor’s
MAX_PIXELSguards the source and not the output, so asking for an 11811 pixel rendition of a 4000 pixel derivative would succeed by scaling it up, allocating hundreds of megabytes and handing the browser a multi-megabyte candidate. A loud failure turning silent is worse than leaving it broken.
Splitting selection from translation would mean the next caller that picks a source and forgets to rescale the crop is one refactor away, so the funnel keeps them adjacent.
One omission in that funnel is a feature rather than an oversight.
When the derivative exists but has no committed transaction id yet, no Thumbor URL
is produced at all.
Substituting the original there would emit a permanent, signed, direct URL naming
an image that may exceed MAX_PIXELS and freeze it into catalog metadata, where a
browser fetches it without Plone in the path and uid healing can never repair it.
Returning nothing yields a uid fallback instead, which heals on the next render
and is corrected for good by the second phase of the backfill.
The draft decode#
Generation opens the source, checks its size against a ceiling, evaluates the
trigger, and only then asks the decoder for a reduced-resolution read through
Image.draft() with mode=None, leaving the colour space alone for the
deliberate conversion that follows.
The obvious call, draft(None, (cap, cap)), delivers less than it looks like it
should, by enough to matter.
JpegImageFile.draft computes a single shared divisor as
min(w // target_w, h // target_h) and then drops to the largest power of two not
exceeding it, so a square target reduces too little on every image that is not
square.
The package computes the divisor itself and passes explicit per-axis targets: the
largest power of two that keeps the result at or above the cap, never below it,
because the resize afterwards still needs pixels to work with.
Original |
Decoded at a 4000 cap |
Peak buffer |
|---|---|---|
11811 x 8858 (104 megapixels) |
5906 x 4429 |
~79 MB |
7000 x 5000 (35 megapixels) |
7000 x 5000, no reduction available |
~105 MB |
Halving the second one would undershoot the cap, so it decodes in full.
For TIFF and PNG, draft is a no-op and a full decode is the price.
The trigger is evaluated against the true source size, before drafting, and the order is load-bearing. Drafting first lands exactly on the cap for any original whose longest edge is the cap times two, four or eight, at which point “larger than the cap” stops being true and precisely the oversized images this exists for would get no derivative.
The decode is bounded on both sides.
MAX_SOURCE_PIXELS, a module constant of 175 megapixels, is checked against the
header before any pixels are read.
It sits in a narrow band on purpose: above the 164.6 megapixel largest image found
in the field, which must still be fixed, and below Pillow’s own hard limit of
178,956,970 pixels, twice MAX_IMAGE_PIXELS, above which Image.open raises
before our own check could look at the size.
Image.MAX_IMAGE_PIXELS itself is never assigned, because it is a process global
that every Image.open consults, including plone.namedfile’s own handling on
every upload, and raising it from a worker thread would disable bomb protection
for everyone for the duration.
Generation runs behind a process-wide semaphore that admits one decode at a time,
with a short timeout.
The mass upload path already serialises on its own lock, but the edit path does
not, and IObjectModifiedEvent can fan out across every worker thread at 79 to
105 MB of pixel buffer each.
A thread that cannot get in promptly records a retry outcome and gives up rather
than queueing, because the work is never urgent: without a derivative the original
is served exactly as it was before.
Colour normalisation#
Size is not the only trigger.
An image also gets a derivative when it is not a clean sRGB raster: CMYK, LAB,
the 16-bit integer modes, or a palette image carrying transparency.
Tying normalisation to size alone would let a 3 megapixel CMYK press image through
unconverted, and CMYK is the normal case for material pulled out of print
layouts.
Conversion goes through the embedded ICC profile when the image has one, using
PIL.ImageCms, and falls back to a plain convert() when it does not, when the
profile turns out to be unusable, or when the Pillow build has no littlecms at
all.
That last case is worth knowing about: stock Pillow wheels bundle liblcms2 on
every platform, and so does the plone/plone-backend image, so the ICC path is
live where it matters.
Where it is not, CMYK falls back to convert() and its naive “255 minus ink”
formula, which shifts press colours noticeably.
A silent degradation is exactly what this design spends its effort avoiding, so
the package logs a warning once per process when ImageCms is unavailable.
Colour is normalised before geometry. Resizing a palette image in palette space interpolates palette indices and produces garbage, so a palette image becomes RGB or RGBA before the resize touches it. The resize itself never enlarges, which matters for a small CMYK image that triggered on colour space alone.
Encoding is JPEG at quality 92 with no chroma reduction, or PNG where transparency
has to survive.
Full chroma resolution is not vanity here: the derivative is an intermediate that
Thumbor scales again, and chroma reduction applied twice produces visible colour
fringing on edges.
EXIF and IPTC are dropped, matching Thumbor’s own PRESERVE_EXIF_INFO = False
default.
EXIF orientation is deliberately not applied: doing so would make images above
and below the cap render differently, since Thumbor’s RESPECT_ORIENTATION
defaults to off, so correcting orientation means correcting both, which is its own
change with its own visual verification.
SVG is skipped, as everywhere else in the package, and animated GIFs are skipped because a derivative would flatten them to the first frame.
What softens, and what stays broken#
A crop covering fraction X of the derivative’s edge feeds cap * X source
pixels into a rendition of width S, so it stays lossless while X >= S / cap.
The binding S is not the largest registered scale: crops are stored per scale
name, so it is the largest scale that actually carries one.
Below the threshold the result softens rather than breaks.
Falling back to the original for very small crops was considered and rejected.
It would need a second setting mirroring Thumbor’s MAX_PIXELS, and a branch that
must not fire for the very images this feature exists to fix.
It earns its place only once a deployment has measured its own crop distribution
and found the threshold binding, which is what the dry run reports.
Choose the source derivative cap walks through that measurement.
Two limitations are accepted rather than fixed:
A truncated source blob yields a truncated derivative, grey where the scan data ran out, rather than no derivative.
plone.scalesetsLOAD_TRUNCATED_IMAGESprocess-wide at import, so those bytes already render grey-padded in every Plone process, and a package that replaces Plone’s scaling should not judge the same bytes more harshly than the scaling it replaces. Turning the flag off for one decode would mutate a process global for every other thread, which is the objection raised againstMAX_IMAGE_PIXELSabove.Images above roughly 179 megapixels get no derivative. Pillow raises inside
Image.open, before the package’s own ceiling can be consulted, and raising the global that governs that is refused for the reason just given. Those images are recorded as failures and keep returning Thumbor’s 400, so they stay enumerable rather than silently missing. Nothing in the field is close to the limit, but an upload above it stays broken.
Replacing an image also orphans its derivative.
The old field value, its derivative and both blobs become garbage the moment a new
NamedBlobImage takes their place, and nothing here collects them, so “one
derivative per qualifying image” is the steady-state figure rather than a storage
bound.
Cache hierarchy#
Images pass through multiple cache layers. Each layer serves a different purpose:
flowchart TB
B[Browser cache] -->|miss| C[CDN / reverse proxy cache]
C -->|miss| T[Thumbor result storage]
T -->|miss: need original blob| D[Blob disk cache]
D -->|miss| PG[(PostgreSQL bytea)]
PG -->|no data column| S3[(S3 object store)]
Layer |
Scope |
Invalidation |
Purpose |
|---|---|---|---|
Browser cache |
Per-user |
URL changes on new TID |
Avoid network requests entirely |
CDN / reverse proxy |
Shared |
URL changes on new TID |
Reduce Thumbor load for popular images |
Thumbor result storage |
Per-Thumbor instance |
URL changes on new TID |
Avoid re-processing (crop, resize, encode) |
Blob disk cache |
Per-Thumbor instance |
LRU eviction by size |
Avoid repeated PG queries for the same original |
PostgreSQL bytea |
Authoritative |
Never (immutable by TID) |
Primary blob storage |
S3 |
Overflow |
Never (immutable by TID) |
Large blob offload for PG space management |
The critical insight is that ZODB TIDs are immutable: when a blob changes, its TID changes, which changes the URL, which is a completely different cache key at every layer. No cache invalidation problem exists – only cache eviction for space management.
The blob disk cache (BlobCache) uses deterministic filenames
({zoid:016x}-{tid:016x}.blob) and LRU eviction by access time. When total cache
size exceeds PGTHUMBOR_CACHE_MAX_SIZE, the oldest-accessed files are removed
until total size drops to 90% of the maximum.
Deployment topology#
The example docker-compose.yml illustrates the reference deployment:
flowchart LR
subgraph External
B[Browser]
end
subgraph Docker Compose
N[nginx :80]
P[Plone :8080]
T[Thumbor :8888]
PG[(PostgreSQL :5432)]
end
B -->|:8080| N
N -->|/| P
N -->|/thumbor/| T
T -->|internal auth| P
P --> PG
T --> PG
nginx serves as the reverse proxy, routing /thumbor/ to the Thumbor container
(stripping the prefix) and everything else to Plone (with VirtualHostMonster
rewriting).
The Thumbor container talks to Plone directly via the Docker network
for auth subrequests (PGTHUMBOR_PLONE_AUTH_URL = http://plone:8080/Plone) –
this bypasses nginx and avoids routing loops.
Both Plone and Thumbor share the same PostgreSQL instance and the same HMAC security key. The security key must be identical on both sides: Plone uses it to sign URLs, Thumbor uses it to verify signatures.