Kai Ole Hartwig — Blog
Planning a Kubernetes migration?

cluster-file-backend — TYPO3 cache for Kubernetes, without a shared filesystem.

Run TYPO3 file-based caches safely across Kubernetes pods — without RWX volumes. cluster-file-backend keeps cache payloads pod-local and cache validity cluster-wide. It replaces TYPO3’s FileBackend / SimpleFileBackend for cache workloads, supports tag-based invalidation, and avoids shared filesystem coupling in multi-pod deployments.

  • Composer package:moselwal/cluster-file-backend:^2.3
  • Extension key:cluster_file_backend
  • TYPO3: 14.3+ (Composer mode only) · PHP: 8.5+ · MIT

TL;DR — Is this right for my setup?

Use this if …

Do not use this if …

Migration effort

One-time setup: register the metadata cache, rewrite the cache configurations, mount an emptyDir. No TYPO3 core changes, no application code changes.

Recommended metadata backend

Production: moselwal/keyvalue-store with KeyValueBackend (Valkey/Redis, sub-ms latency, taggable). Getting started without an extra dependency: TYPO3 Core’s Typo3DatabaseBackend.

Packagist

composer require moselwal/cluster-file-backend:^2.3

Paket auf Packagist →
Packagist

GitHub

Quellcode, Issues und Changelogs. MIT-lizenziert.

Auf GitHub ansehen →
GitHub

What’s new in v2.2 / v2.3

PhpCapableBackendInterface (v2.2)

Since v2.2 ClusterFileBackend also implements PhpCapableBackendInterface — a single backend serves both VariableFrontend caches (pages, extbase, …) and PhpFrontend caches (typoscript, fluid_template). The payload store appends a .php suffix for PhpFrontend caches so OPcache can ingest the files directly. Compression is forced to none for PHP code. Cluster coherence comes from the BackendVersion-folded hash path — every deploy produces a new path, OPcache cools automatically, no opcache_invalidate() needed.

One-byte compression marker (v2.2)

0x00 = uncompressed, 0x01 = zstd, 0x02 = gzip. The reader picks the decompressor from the marker; the writer can mix codecs (e.g. skip-compress for tiny payloads, zstd for the rest).

Skip-compress for small payloads (v2.2)

Option minCompressedBytes (default 1024): payloads below this threshold are stored uncompressed. Avoids the fixed cost of gzdeflate/zstd_compress on tiny values. 0 = always compress.

Request-scoped metadata L1 (v2.2)

has() / get() / remove() hit an in-memory map of recent CacheMetadata lookups. Identical identifiers in the same request skip the Valkey/DB round-trip — ~200× faster on repeated has() calls.

Request-scoped payload L1 (v2.3)

Fully decoded payloads are cached in RAM with LRU (default cap: 32 entries / 4 MB). Repeated get() calls on a hot identifier drop from ~90 µs to ~0.5 µs — 9–20× faster than SimpleFileBackend on every repeated read. Controlled via payloadL1MaxEntries and payloadL1MaxBytes. PhpFrontend caches skip the payload L1 (OPcache is the better in-memory representation).

Prometheus counter cache_l1_hit_total (v2.3)

Alongside cache_hit_total and cache_miss_total, the new counter cache_l1_hit_total makes the three-layer hit distribution visible in production. Alert on cache_miss_total{reason=metadata-error} for early detection of metadata cache outages.

Three-layer storage and architecture

┌─ FrankenPHP worker RAM ─────────────────────────────────────────┐
│  OPcache (compiled PHP code)         → PhpFrontend .php files  │
│  Payload L1 (decompressed bytes)     → VariableFrontend caches │
│  Metadata L1 (CacheMetadata objects) → all caches              │
└─────────────────────────────────────────────────────────────────┘
                              ▲
┌─ Pod-local emptyDir ──────────────────────────────────────────┐
│  /<localPath>/<shard>/<sha256>[.php]                            │
│  • VariableFrontend: 1-byte marker + compressed bytes          │
│  • PhpFrontend: plain-text PHP with .php suffix               │
│  → source of truth for payload bytes                           │
└─────────────────────────────────────────────────────────────────┘
                              ▲
┌─ Metadata cache (Valkey / DB) ─────────────────────────────────┐
│  ~300-byte records: { hash, checksum, lifetime, tags, state }  │
│  → source of truth for cluster-wide cache validity              │
│  → no PHP code, no payload bytes                               │
└─────────────────────────────────────────────────────────────────┘

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

Planning a TYPO3 Kubernetes migration? I can review your cache, storage and deployment architecture before it becomes an operations problem. Get in touch →

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:^2.3
  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. Forced to none for PhpFrontend caches.
serializerigbinaryigbinary | php. Switching invalidates existing entries.
defaultLifetimeSeconds3600TTL when the caller passes null. Minimum 1 (schema rejects 0).
maxPayloadBytes10485760 (10 MB)Writes larger than this are rejected with InvalidDataException. Also the upper bound for decompressed reads (zstd-bomb mitigation).
minCompressedBytes1024Payloads below this size are stored uncompressed (1-byte marker 0x00). Avoids the fixed cost of zstd_compress/gzdeflate on tiny values. 0 = always compress.
payloadL1MaxEntries32Maximum entries in the request-scoped payload L1. 0 disables the payload L1 (metadata L1 stays active). LRU eviction.
payloadL1MaxBytes4194304 (4 MB)Soft memory budget for the payload L1. Entries that would push the total above this are evicted in insertion order; a single payload larger than this bypasses the L1 entirely. 0 = no byte budget.
backendVersionEnvVarIMAGE_TAGEnv var carrying the deploy identifier. Folded into every payload hash — every deploy gets a fresh path tree. Override for CI conventions like CI_COMMIT_SHA.

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 fileΘ(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

Operational requirements

Pod clock synchronisation

Cache lifetimes are evaluated against each pod’s local clock. If pods disagree on wall-clock time by more than a few seconds, a pod with a fast clock treats entries as expired earlier than peers — correctness is preserved but performance degrades. In Kubernetes this is normally a non-issue (nodes synchronise via chrony/systemd-timesyncd). Sanity check during incidents:

 

kubectl exec deploy/typo3 -- date -u

 

A skew above ~30 seconds across pods is the threshold where blob_miss_total and cache_miss_total{reason=expired} start to drift visibly in Prometheus.

Metadata cache availability

The metadata cache (Redis/Valkey/DB) is the single source of truth. When it is unreachable:

Compatible metadata cache backends

The metadata cache must use a backend that implements TaggableBackendInterface; otherwise flushByTag becomes a no-op. Verified backends:

BackendTaggableNotes
Typo3DatabaseBackendZero-dependency default
KeyValueBackend (moselwal/keyvalue-store)Redis/Valkey, recommended for high-traffic
MemcachedBackend (TYPO3 Core)Does not support tags — incompatible
RedisBackend (TYPO3 Core)Not taggable — use moselwal/keyvalue-store instead

IMAGE_TAG consistency across all pods

Every container pointing at the same metadata cache backend must see the same IMAGE_TAG (or whichever variable is set via backendVersionEnvVar). If the web pod runs IMAGE_TAG=1.2.3 and a worker/cron pod still runs IMAGE_TAG=1.2.2, the two compute different BackendVersion values and treat each other’s writes as blob-misses. Symptom: persistent thrashing in mixed deployments. Helm/Kustomize tip: extract the tag into a single value and reference it from every Pod spec.

Y2K38 limitation for unlimited-lifetime entries

Lifetime::unlimited() maps to expiresAt = 2147483647 (mirrors TYPO3 Core’s Typo3DatabaseBackend::FAKED_UNLIMITED_EXPIRE). On 2038-01-19 03:14:07 UTC that timestamp becomes “now” and any entry stored as unlimited will be considered expired. Practical impact before then: none.

crc32-based BackendVersion folding

BackendVersion::fromString(…) folds the deploy identifier via crc32 to a 32-bit integer. Birthday collisions occur at ~77 000 unique deploy identifiers — extremely unlikely under realistic release cadence. If you regularly cycle through thousands of distinct identifiers, prefer a stable, human-readable semver string over raw commit SHAs.

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 I use this …

This package handles the file-based cache 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.