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

What it is not

Requirements

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

  1. Composer install: composer require moselwal/cluster-file-backend:^1.0.1
  2. 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, install moselwal/keyvalue-store and use its KeyValueBackend.
  3. Register a TYPO3 cache frontend (convention: cluster_meta) that holds the metadata.
  4. Reconfigure the file-based TYPO3 caches (pages, pagesection, rootline, imagesizes, assets, hash) to use ClusterFileBackend and reference cluster_meta via metadataCacheIdentifier.
  5. Mount a pod-local emptyDir at /app/var/cache/cluster/ (or wherever localPath points).

What the package ships

ArtefactPathPurpose
Default config (no extra deps)Configuration/Example/cache-configurations.example.phpDatabase-backed metadata plus cluster file caches — works on any TYPO3 install
Redis/Valkey config (optional)Configuration/Example/cache-configurations-redis.example.phpHigh-performance variant using moselwal/keyvalue-store
JSON SchemaConfiguration/Backend/ClusterFileBackend.options.schema.jsonValidated at backend construction — misconfiguration raises InvalidCacheException with the offending field
CLI commandsConfiguration/Commands.phpclusterfilebackend:gc, clusterfilebackend:warmup
Event listenerConfiguration/Services.yamlHooks into TYPO3's CacheWarmupEventbin/typo3 cache:warmup triggers the cluster warm-up too
DI bindingsConfiguration/Services.yamlAuto-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().

OptionDefaultMeaning
compressionzstdzstd | gzip | none
serializerigbinaryigbinary | php
defaultLifetimeSeconds3600TTL when the caller passes null
maxPayloadBytes10485760 (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.

OperationTYPO3 Core FileBackendClusterFileBackendSpeedup
flushByTagΘ(n) per pod — DirectoryIterator across every cache file, 2× file_get_contents per fileO(m) — backend reads the tag index directlyDifferent complexity class plus tag indexes
findIdentifiersByTagΘ(n) per podO(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-sidePod factor disappears, constants ~100–1000× smaller

Concrete example

n = 10,000 cache entries, of which m = 100 are tagged site_1, P = 5 pods.

SetupFile readsunlink callsRound-trips
Core FileBackend on flushByTag('site_1')2 · n = 20,000m = 100≈ 2n + m = 20,100 local FS I/O per pod
ClusterFileBackend (Redis)002 (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:

  1. Pod-old writes payload v1 → metadata holds hashv1.
  2. 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.
  3. Pod-old reads, sees hashv2, has no local blob → blob-miss → rebuilds v1 → metadata reverts to hashv1.
  4. 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

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

Common pitfalls

Next step

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.

Discuss the K8s 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.