cluster-file-backend — TYPO3 cache for Kubernetes, without a shared filesystem.
Drop-in replacement for FileBackend and SimpleFileBackend in Kubernetes deployments. Cache validity comes from a second TYPO3 cache frontend (backend freely chosen), payloads are materialised pod-locally as atomically written files. No RWX volume between pods, deterministic re-materialisation via sha256 hash validation, cluster-wide tag-based invalidation, deployment-time warm-up.
Architecture in one diagram
This package knows nothing about Redis/Valkey/KV stores. It only speaks the TYPO3 cache API and delegates cluster persistence to a TYPO3 cache backend of your choice.
TYPO3 Cache API → ClusterFileBackend
│
├─► Metadata cache (a second TYPO3 cache frontend;
│ backend is your choice: Typo3DatabaseBackend,
│ KeyValueBackend, MemcachedBackend, …)
│
└─► Local payload store (pod-local, emptyDir)
What it is
- No RWX volume required between pods
- Central cache validity via the TYPO3 cache API
- Deterministic re-materialisation via sha256 hash validation
- Cluster-wide tag-based invalidation (via TYPO3
TaggableBackendInterface) - Garbage collection via CLI (
clusterfilebackend:gc), delegated to the metadata cache backend - Deployment-time warm-up via CLI (
clusterfilebackend:warmup) and a listener on TYPO3'sCacheWarmupEvent
What it is not
- Not a replacement for TYPO3 FAL, fileadmin, the TYPO3 core code cache (
var/cache/code/corestays in the image), a session store, a generic blob store or a distributed filesystem - Carries no Redis/Valkey knowledge — install a TYPO3 cache backend for it (e.g. the
KeyValueBackendfrommoselwal/keyvalue-store) and pointClusterFileBackendat it viametadataCacheIdentifier
Requirements
- TYPO3 14.3+ (Composer-mode only, no
ext_emconf.php, no classic mode) - PHP 8.5+
- Composer package
moselwal/cluster-file-backendfrom^1.0.1, extension keycluster_file_backend, namespaceMoselwal\Typo3ClusterCache\ - License MIT
Setup prerequisites — the one-time steps
The package intentionally does not register any caches automatically. Hostnames, ports, TLS, paths are inherently site-specific. The steps below are a one-time setup.
Five required steps
- Composer install:
composer require moselwal/cluster-file-backend:^1.0.1 - Provide a cluster-capable cache backend for metadata. Out of the box, the default uses TYPO3 Core's
Typo3DatabaseBackend— works without extra dependencies as long as the database is reachable from every pod (Galera, RDS Multi-AZ, …). For higher performance, installmoselwal/keyvalue-storeand use itsKeyValueBackend. - Register a TYPO3 cache frontend (convention:
cluster_meta) that holds the metadata. - Reconfigure the file-based TYPO3 caches (
pages,pagesection,rootline,imagesizes,assets,hash) to useClusterFileBackendand referencecluster_metaviametadataCacheIdentifier. - Mount a pod-local
emptyDirat/app/var/cache/cluster/(or whereverlocalPathpoints).
What the package ships
| Artefact | Path | Purpose |
|---|---|---|
| Default config (no extra deps) | Configuration/Example/cache-configurations.example.php | Database-backed metadata plus cluster file caches — works on any TYPO3 install |
| Redis/Valkey config (optional) | Configuration/Example/cache-configurations-redis.example.php | High-performance variant using moselwal/keyvalue-store |
| JSON Schema | Configuration/Backend/ClusterFileBackend.options.schema.json | Validated at backend construction — misconfiguration raises InvalidCacheException with the offending field |
| CLI commands | Configuration/Commands.php | clusterfilebackend:gc, clusterfilebackend:warmup |
| Event listener | Configuration/Services.yaml | Hooks into TYPO3's CacheWarmupEvent — bin/typo3 cache:warmup triggers the cluster warm-up too |
| DI bindings | Configuration/Services.yaml | Auto-discovery for MetricsPort, ClockPort, CompressorPort |
Constructor validation
The ClusterFileBackend constructor validates options against a JSON schema. Mandatory fields (otherwise InvalidCacheException): localPath (absolute path), metadataCacheIdentifier (name of the metadata cache frontend), namespace.environment (prod, staging, testing or development) and namespace.instance (slug [a-z0-9-]{1,64}). If the configured metadataCacheIdentifier is not registered as a TYPO3 cache, the constructor fails immediately with a message naming the config path — no silent failure on first set().
| Option | Default | Meaning |
|---|---|---|
compression | zstd | zstd | gzip | none |
serializer | igbinary | igbinary | php |
defaultLifetimeSeconds | 3600 | TTL when the caller passes null |
maxPayloadBytes | 10485760 (10 MB) | Writes larger than this are rejected with InvalidDataException |
Configuration — quick start and variants
Quick start (zero extra dependencies)
Copy the contents of vendor/moselwal/cluster-file-backend/Configuration/Example/cache-configurations.example.php into your config/system/settings.php (or additional.php) and adjust environment, instance and localPath to your deployment. This example uses TYPO3 Core's Typo3DatabaseBackend for the metadata cache — cluster-safe when your database is clustered.
Redis/Valkey variant
For sub-millisecond metadata latency, copy Configuration/Example/cache-configurations-redis.example.php instead. It uses the KeyValueBackend from moselwal/keyvalue-store with optional TLS and Sentinel support.
Manual setup
Step 1: Define a TYPO3 cache frontend that persists metadata. Any backend that implements TaggableBackendInterface (for flushByTag) works.
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['cluster_meta'] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend::class,
'options' => [],
'groups' => ['system'],
];
Step 2: Point ClusterFileBackend at the metadata cache — for all file-based caches at once.
foreach (['pages', 'pagesection', 'rootline'] as $cacheName) {
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'][$cacheName] = [
'frontend' => \TYPO3\CMS\Core\Cache\Frontend\VariableFrontend::class,
'backend' => \Moselwal\Typo3ClusterCache\Infrastructure\Cache\Backend\ClusterFileBackend::class,
'options' => [
'localPath' => '/app/var/cache/cluster/' . $cacheName,
'metadataCacheIdentifier' => 'cluster_meta',
'namespace' => [
'environment' => 'prod',
'instance' => 'website-a',
],
],
'groups' => ['pages'],
];
}Kubernetes deployment, warm-up and garbage collection
Pod volume for payloads
volumes:
- name: cluster-cache
emptyDir: { sizeLimit: 2Gi }
volumeMounts:
- name: cluster-cache
mountPath: /app/var/cache/cluster
Deployment-time warm-up
After a rolling deploy each new pod should typically verify that it can reach the metadata cache and that its localPath is writable before it starts serving traffic. Trigger the warm-up explicitly:
./vendor/bin/typo3 clusterfilebackend:warmup \
--namespace=cfb:prod:website-a:pages \
--namespace=cfb:prod:website-a:pagesection \
--namespace=cfb:prod:website-a:rootline
The command emits one JSON line per namespace and exits non-zero if any namespace fails its health checks. Hook it into your readiness/startup probe or a post-deploy job.
Alternatively, run TYPO3's standard warm-up — the event listener hooks in automatically:
./vendor/bin/typo3 cache:warmup
Garbage collection as a CronJob
apiVersion: batch/v1
kind: CronJob
metadata:
name: clusterfilebackend-gc-pages
spec:
schedule: "*/15 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
containers:
- name: typo3-cli
args: ["clusterfilebackend:gc", "--namespace=cfb:prod:website-a:pages"]
Internal architecture
DDD 4-layer (Domain → Application → Infrastructure → Presentation), enforced via deptrac. The only outside interface for “central truth” is MetadataCachePort, implemented by the Typo3MetadataCache adapter, which accepts any TYPO3 FrontendInterface.
Cluster consistency — what happens on cache clear?
Frequently asked: “When an editor clicks Clear all caches in the TYPO3 backend, how do we make sure all pods see it?”
Short answer: the pod handling the click clears the central metadata cache. All other pods see it on their next get() because they query the central metadata cache, not their local filesystem. No pod-to-pod sync needed, because metadata truth never lives on a pod.
Detailed flow
Pod A: TYPO3 backend “Clear all caches” / editor saves page /
`bin/typo3 cache:flush`
│
▼
ClusterFileBackend::flush() on pod A
│
▼ delegates to metadata cache frontend (e.g. cluster_meta)
$metadataCache->flush()
│
▼ TYPO3 cache API calls the configured backend
KeyValueBackend / DatabaseBackend / MemcachedBackend → flush()
│
▼ happens SERVER-SIDE (Redis FLUSHDB, SQL TRUNCATE, Memcached flush_all)
All pods see the empty metadata immediately
On the next get(id) on any pod:
$metadata = $this->metadataCache->get($identifier); // → null (cache flushed)
if ($metadata === null) {
// cache_miss_total{reason=no-metadata}++
return null; // ← pod does NOT consult its local FS
}
Verified by the test suite
Tests/Unit/Deployment/CrossPodFlushTest.php contains five tests that prove this: flush() propagates to pod B immediately, no sync; flushByTag() invalidates only matching entries; the local file survives the flush as a harmless orphan; re-write after flush re-establishes consistency; flush works for arbitrary numbers of pods (no scaling assumption).
Complexity — why it is faster in a cluster
Let C be the set of all cache entries and Ct ⊆ C the subset of entries tagged with t. Write n := |C| for the total count and m := |Ct| for the count of t-tagged entries — with m ≤ n. This makes the difference precise: ClusterFileBackend sits in a different complexity class, not merely at a smaller argument, because it uses backend-native algorithms and does not multiply by a pod factor.
Let P denote the number of pods and e ≤ n the number of expired entries.
| Operation | TYPO3 Core FileBackend | ClusterFileBackend | Speedup |
|---|---|---|---|
flushByTag | Θ(n) per pod — DirectoryIterator across every cache file, 2× file_get_contents per file | O(m) — backend reads the tag index directly | Different complexity class plus tag indexes |
findIdentifiersByTag | Θ(n) per pod | O(m) | same |
collectGarbage | Θ(n) per pod, total Θ(n · P) | O(1) active (Redis TTL auto-expire) or O(e) server-side (DB) | Backend-native plus cluster-once |
flush | Θ(n) per pod, total Θ(n · P) | Θ(n) once server-side | Pod factor disappears, constants ~100–1000× smaller |
Concrete example
n = 10,000 cache entries, of which m = 100 are tagged site_1, P = 5 pods.
| Setup | File reads | unlink calls | Round-trips |
|---|---|---|---|
Core FileBackend on flushByTag('site_1') | 2 · n = 20,000 | m = 100 | ≈ 2n + m = 20,100 local FS I/O per pod |
| ClusterFileBackend (Redis) | 0 | 0 | 2 (SMEMBERS + pipeline DEL) once cluster-wide |
Rolling deploys with version skew
During a rolling deploy old and new pods serve traffic simultaneously. ClusterFileBackend preserves correctness in every skew scenario, but two cases change the performance profile during the deploy window — worth understanding.
A) Application code with changed cache layout
If the new image writes a different payload shape for the same cache identifier (extra fields, changed serialised classes, modified value objects) and you do not explicitly invalidate, the following happens:
- Pod-old writes payload v1 → metadata holds hashv1.
- Pod-new reads, sees hashv1, has no local blob → blob-miss → TYPO3 frontend asks the caller to rebuild → pod-new writes payload v2 → metadata is overwritten with hashv2.
- Pod-old reads, sees hashv2, has no local blob → blob-miss → rebuilds v1 → metadata reverts to hashv1.
- Hash thrashing for the duration of the rolling deploy.
The bigger risk is silent layout drift: if pod-new can technically deserialise pod-old’s bytes but the resulting object is wrong (missing fields, old enum cases, removed properties), the user sees stale or corrupt content. PHP’s unserialize does not verify class shape beyond the class name.
Recommendation: tie cache identity to the deploy
So that every release automatically gets a new BackendVersion and stale entries become unreachable, ClusterFileBackend reads an environment variable — by default IMAGE_TAG — and folds its value into the payload hash via crc32. In your deployment manifest:
# Helm values, Kustomize patch or plain Pod spec
env:
- name: IMAGE_TAG
value: "{{ .Values.image.tag }}" # or $CI_COMMIT_SHA, release semver, ...
You can override the variable name per cache if your CI convention differs:
'options' => [
'localPath' => '/app/var/cache/cluster/pages',
'metadataCacheIdentifier' => 'cluster_meta',
'namespace' => ['environment' => 'prod', 'instance' => 'site'],
'backendVersionEnvVar' => 'CI_COMMIT_SHA',
],
When the variable is unset or empty, the backend falls back to the package-internal BackendVersion::current() — safe for local development. In production wire the variable explicitly to get deploy-scoped invalidation.
Alternative invalidation strategies
- Pre-flush via
clusterfilebackend:warmupin the deploy pipeline — drains stale entries before the new image takes traffic. - Rename the cache identifier (e.g.
pages→pages_v2incacheConfigurations). Heavy hammer, only for larger schema reworks.
For non-breaking layout changes (additive, ignored by the old code) you can accept the temporary thrashing — correctness is preserved.
B) PHP major/minor version change
The identity hash includes PHP_MAJOR.PHP_MINOR (Classes/Application/Hash/ComputePayloadHash.php). PHP 8.4 ↔ 8.5 (or any other major/minor jump) automatically produces divergent hashes — no manual action needed. Correctness guaranteed. The cost is the same thrashing as in (A) for the duration of the rollout. Watch blob_miss_total in Prometheus; a sustained spike beyond the deploy window indicates the version skew did not converge (e.g. a pod stuck in the old image).
PHP patch updates (8.5.4 → 8.5.5) do not invalidate — only major and minor are in the hash.
Operational recommendation
- Patch updates (igbinary patch, PHP patch, app bug-fix without cache layout change): plain rolling deploy, no extra steps.
- Minor and major updates (PHP minor bump, BackendVersion bump, cache-layout change): rolling deploy stays safe but expect a blob-miss spike. For zero-degradation deploys, run a
Recreatestrategy or pre-flush via the warm-up command.
Common pitfalls
localPathmust be writable. With a read-only/appimage, mountemptyDir/tmpfsat that path.- Identical container image across all pods. Different PHP or igbinary versions produce divergent hashes → permanent blob-misses. Major versions are enough — patch versions are not in the hash (since v1.0.1).
- Wire
IMAGE_TAG(or your equivalent) in production. Without it the backend uses a package-internal version constant that does NOT change across deploys — breaking cache-layout changes can then silently serve stale or corrupt content. See “Rolling deploys with version skew”. metadataCacheIdentifiermust be registered before any cache that usesClusterFileBackend. TYPO3 loadscacheConfigurationsin array insertion order — definecluster_metafirst.- Composer mode only. No
ext_emconf.php, no classic mode.
Running TYPO3 on Kubernetes?
If you run TYPO3 on a K8s cluster without an RWX volume, or want to take an existing FileBackend setup into multi-pod operation, cluster-file-backend is the right primitive. Get in touch for architecture advice, migration or platform setup.
Or email us directly: kontakt@moselwal.de
Where we use this …
This package carries the fileadmin and object storage layer in TYPO3 Kubernetes — one of the prerequisites for multi-pod clusters as described under Open Source & Digital Sovereignty. Managed variant: AI-Ready CMS as a Service.
