TYPO3-fileadmin auf S3 — ohne dass jeder Request nach S3 geht
„S3 als Dateisystem“ klingt bequem: unbegrenzter, haltbarer Speicher, den sich mehrere Pods teilen. Der Preis dafür steht im Kleingedruckten — jeder Datei-Read kann ein API-Call zu AWS sein. Bei einer media-lastigen TYPO3-Site heißt das schnell: tausende S3-Requests pro Seitenaufruf, Latenz auf jedem Bild und eine Rechnung, die mit dem Traffic wächst. Dieser Beitrag zeigt, wie ich TYPO3s fileadmin auf S3 betreibe, ohne dass genau das passiert.
TL;DR
Der fileadmin (Uploads, Bilder, skalierte Varianten) liegt in einem S3-Bucket und ist per GeeseFS als Volume in den TYPO3-Pod gemountet — schlüssellos über die EC2-Instanz-Rolle, ohne statische Keys im Cluster. Der entscheidende Hebel ist aber nicht der Mount: /fileadmin/* wird gar nicht von TYPO3/PHP beantwortet, sondern vom Webserver als statische, content-adressierte Datei mit Cache-Control: immutable. Davor liegen drei Cache-Ebenen: Browser (ein Jahr), Souin-Shared-Cache im Reverse Proxy (RAM, 1 h) und der GeeseFS-Page-Cache (512 MB RAM). Das Ergebnis: Die S3-GET-Requests sind der kleinste Posten der gesamten S3-Rechnung — noch unter dem reinen Storage. Reads sind praktisch gratis.
Das Setup in einem Satz
Ein Single-Node-k3s auf AWS Graviton (arm64). TYPO3 läuft als FrankenPHP-Pod, der fileadmin liegt in einem S3-Bucket und ist per FUSE-Treiber als normales ReadWriteMany-Volume in den Pod gemountet. Davor ein caddy-proxy mit HTTP-Cache. So weit, so normal — interessant wird es beim „Wie“.
Der Mount: S3 als Volume, keyless und arch-korrekt
Der fileadmin-Mount läuft über den yandex k8s-csi-s3-Treiber mit GeeseFS als Mounter. GeeseFS ist ein FUSE-Dateisystem für S3, das — wichtig für später — Dateiinhalte im RAM cachet.
# Helm values des Tenant-Charts
s3:
bucket: koh-web-<site>-media
region: eu-north-1
mounter: geesefs
# --iam -> EC2-Instanz-Rolle via IMDS. Keine statischen Keys im Cluster.
# --memory-limit 512 -> 512 MB In-Memory-Page-Cache
mountOptions: "--iam --region eu-north-1 --memory-limit 512 --dir-mode 0777 --file-mode 0666"
mountPath: /app/public/fileadmin
Drei Dinge, über die du hier leicht stolperst.
Keyless via Instanz-Rolle
--iam lässt GeeseFS die EC2-Instanz-Rolle über IMDS nutzen. Es liegen keine S3-Access-Keys im Cluster — ein Endpoint/Region-Secret genügt, die Auth bleibt schlüssellos. Ein kompromittierter Pod bekommt keine dauerhaften Credentials in die Hand.
arm64-Treiber selbst gebaut
Der Cluster ist Graviton. Die offiziellen Treiber-Images sind amd64-only und quittieren das auf ARM mit exec format error. Also baue ich den CSI-Treiber selbst als arm64; die Standard-Sidecars (provisioner/registrar) sind ohnehin multi-arch. Ein Muster, das sich bei Graviton durch das ganze Setup zieht: multi-arch nehmen oder selbst bauen.
World-writable Modes (0777/0666)
Der Treiber hardcodet den Owner auf nobody (65534) und ignoriert --uid/--gid. Damit TYPO3 (www-data, UID 1000) skalierte Bilder nach fileadmin/_processed_/ schreiben kann, bleibt nur der Weg über die Datei-Modes. Sieht wild aus, ist im Kontext vertretbar: Im Pod läuft nur TYPO3, und der Bucket selbst ist per IAM geschützt — die „world“ endet an der Pod-Grenze.
Die eigentliche Idee: fileadmin geht nicht durch PHP
Der wichtigste Hebel ist nicht der Cache, sondern wie eine Bild-URL beantwortet wird. Statische Assets rendert nicht TYPO3 — der Webserver liefert /fileadmin/* mit einem simplen file_server direkt vom Mount aus und markiert sie als unveränderlich:
@fileadminDir { path /fileadmin/* }
handle @fileadminDir {
header Cache-Control "public, max-age=31536000, immutable"
file_server { root /app/public }
}
Kein PHP-Bootstrap, kein TYPO3-Frontend, kein Datenbank-Query — nur ein Datei-Read. Und immutable mit einem Jahr Cache-Lebensdauer ist hier sicher, weil TYPO3 bearbeitete Bilder content-adressiert ablegt: Der Bildinhalt bestimmt den Dateinamen (Hash im Pfad). Ändert sich das Bild, ändert sich die URL — der alte Cache-Eintrag wird schlicht nie wieder angefragt. Kein Invalidieren nötig.
Das ist der Kniff, der alles Weitere trägt: Weil die Antwort statisch und unveränderlich ist, dürfen alle Ebenen davor sie beliebig lange behalten.
Drei Cache-Ebenen entlang des Pfads
Browser
│ (1) Browser-Cache: Cache-Control: immutable, max-age=1 Jahr
▼
Ingress (Traefik, öffentliches TLS)
│
▼ mTLS
caddy-proxy ── (2) Souin Shared-Cache (RAM, TTL 1h, stale-while-revalidate)
│
▼ mTLS
FrankenPHP-Pod (file_server)
│
▼
GeeseFS-Mount ── (3) In-Memory-Cache (512 MB RAM)
│
▼
S3 ← nur bei kaltem Objekt auf kaltem Cache
Ebene 1: Browser mit immutable
Der beste Request ist der, der nie stattfindet. Ein wiederkehrender Besucher lädt das Bild kein zweites Mal. Damit das funktioniert, muss der immutable-Header bis zum Browser durchkommen: Mein caddy-proxy schreibt Cache-Control nur für HTML-Seiten um und nimmt /fileadmin/*, gebündelte Assets und typo3temp/* ausdrücklich aus. Statische Dateien behalten also ihr „ein Jahr, unveränderlich“.
Ebene 2: Souin — Shared-Cache im Reverse Proxy
Mein caddy-proxy ist ein mit xcaddy gebautes Golden Image mit Rate-Limiting, CrowdSec-WAF und dem Souin-HTTP-Cache. Souin cached GET-Antworten geteilt über alle Besucher:
cache {
ttl 1h
stale 60m
default_cache_control "public, max-age=600, stale-while-revalidate=3600, stale-if-error=3600"
# TYPO3-Backend & dynamische Endpunkte nie cachen:
regex { exclude "/typo3/*|/login|/module/.*|/ajax/.*|/install/.*" }
}
Der erste Besucher eines Bildes füllt den Cache, alle folgenden werden aus Caddys RAM bedient — App-Pod, Mount und S3 sehen diese Requests nie. stale-while-revalidate liefert dabei auch bei einem langsamen Ursprung sofort eine (leicht veraltete) Antwort aus, während im Hintergrund aktualisiert wird.
Ebene 3: GeeseFS In-Memory-Cache
Was doch an Souin vorbeirutscht — etwa direkt nach einem Deploy, wenn Caddys RAM-Cache kalt ist — fängt der 512-MB-Page-Cache von GeeseFS ab. Erst ein wirklich kaltes Objekt auf kaltem Cache erzeugt einen echten S3-GET.
Ehrlich zur dritten Ebene: Das ist reiner RAM, kein Disk-Cache. Auf einem großen Media-Bestand ist Ebene 3 also klein — die langlebige Zwischenspeicherung leisten Ebene 1 (Browser) und 2 (Souin). GeeseFS' Cache ist eher ein Stoßdämpfer als ein Reservoir.
Invalidierung: bei fileadmin fast ein Nicht-Problem
Weil fileadmin-Inhalte content-adressiert und immutable sind, müssen sie praktisch nie invalidiert werden. Ein geändertes Bild bekommt eine neue URL, der alte Cache verfällt einfach ungenutzt. Cache-Invalidierung ist damit fast ausschließlich ein Thema für HTML/CSS/JS bei Deploys — nicht für Media.
Zwei Erfahrungswerte, die du beim Nachbauen kennen solltest.
Souin mit In-Memory-Store lässt sich nicht zuverlässig per API-PURGE leeren
Ich habe live festgestellt, dass ein PURGE auf den Default-In-Memory-Store nicht verlässlich greift. Der belastbare Weg nach einem Deploy ist deshalb ein kubectl rollout restart des caddy-proxy (zero-downtime per RollingUpdate), der den RAM-Cache sauber wegwirft — ausgelöst als GitOps-PostSync-Hook. Für gezieltes Content-Purging bräuchte es einen shared Souin-Store (Redis/Badger); das ist bewusst noch nicht drin.
Die Cache-API ist nur intern erreichbar
Der Admin-Port des Proxys hört nur auf localhost, die Invalidierungs-Route liegt auf dem internen mTLS-Listener und ist per Client-IP auf das Cluster-Netz beschränkt (extern → 403). Ein „Cache leeren“-Endpunkt gehört nie ins öffentliche Internet.
Der 404-Fallstrick, der dich ein Jahr lang verfolgt
immutable auf einer fehlenden Datei ist eine Falle: Ohne Absicherung würde der Server einen 404 als „ein Jahr unveränderlich“ ausliefern, und Souin würde diesen 404 entsprechend lange festhalten — bis zum nächsten Neustart, und das nach jeder Asset-Umbenennung. Deshalb greift der immutable-Header nur auf tatsächlich existierende Dateien (@existing file-Matcher). Ein Ein-Zeilen-Guard, dessen Fehlen richtig wehtut.
Was auf der S3-Rechnung sichtbar wird
Die Kostenstruktur bestätigt das Design: Die GET-Requests — also genau das, was Besucher am häufigsten auslösen — sind der kleinste Posten der S3-Rechnung, noch unter dem reinen Storage. Reads sind praktisch gratis, obwohl die Sites ständig Bilder ausliefern. Genau dieses Bild entsteht, wenn Read-Caching funktioniert: Was am häufigsten angefordert wird, wird fast nie tatsächlich aus S3 gelesen.
Eine Beobachtung gehört zur ehrlichen Einordnung: Schreiben und Listen (Request-Tier 1) kostet auf der Rechnung mehr als Lesen (Tier 2). Ein FUSE-Dateisystem macht viele LIST/HEAD-Operationen, um Verzeichnisse abzubilden — die fallen in die teurere Tier-1-Kategorie. Wer S3-Kosten senken will, sollte also nicht nur an GET-Caching denken, sondern auch an die Metadaten-Operationen des Mounts.
Häufige Fragen
Sind 0777/0666-Modes auf dem Mount nicht gefährlich?+
Isoliert betrachtet ja. Im Kontext: Im Pod läuft ausschließlich TYPO3, der Treiber hardcodet den Owner auf nobody und ignoriert --uid/--gid — die Modes sind der einzige Weg, damit www-data nach _processed_/ schreiben kann. Der Bucket selbst ist per IAM geschützt; die „world“ endet an der Pod-Grenze.
Was passiert direkt nach einem Deploy, wenn der Souin-Cache kalt ist?+
Dann fängt der GeeseFS-Page-Cache die Requests ab. Erst ein wirklich kaltes Objekt auf kaltem Cache erzeugt einen echten S3-GET — und der füllt beide Ebenen gleich wieder auf. Dass die GET-Requests trotzdem der kleinste Posten der Rechnung bleiben, zeigt, wie selten das passiert.
Warum kein Disk-Cache für GeeseFS?+
Der GeeseFS-Cache ist bewusst nur ein 512-MB-Stoßdämpfer im RAM. Die langlebige Zwischenspeicherung leisten Browser (immutable, ein Jahr) und Souin — beide greifen, bevor der Request den Mount überhaupt erreicht. Ein Disk-Cache würde an der dritten Ebene optimieren, während die ersten beiden den Traffic längst abfangen.
Fazit
„S3 als Volume“ ist erst dann günstig und schnell, wenn du verhinderst, dass jeder Request auch wirklich bei S3 landet. Der größte Hebel war nicht der FUSE-Cache, sondern die Entscheidung, fileadmin an TYPO3/PHP vorbei als statische, content-adressierte, immutable-Datei zu servieren — und dann drei billige Cache-Ebenen davorzuschalten: Browser, Souin, GeeseFS-RAM.
Das Ergebnis: Read-Kosten, die auf der S3-Rechnung praktisch verschwinden — für eine media-lastige TYPO3-Landschaft. Der Rest ist Disziplin — schlüssellose Auth, arch-korrekte Images und ein 404-Guard, der dich davor bewahrt, dir einen Fehler ein Jahr lang zu cachen.
Cloud-Kosten runter, ohne Performance-Verlust
Wenn deine TYPO3- oder Kubernetes-Plattform bei jedem Bild nach S3 greift — oder deine AWS-Rechnung mit dem Traffic wächst —, schaue ich mir mit dir an, wo Caching, Architektur und Auslieferungspfad auseinanderlaufen. Ergebnis: statische Assets am PHP-Stack vorbei, Cache-Ebenen, die sich gegenseitig absichern, und Kosten, die du erklären kannst.
Freiberuflich · DevSecOps-Beratung, Schulungen & Softwareentwicklung
Über den Autor

Kai Ole Hartwig
Programmiert seit 2002 – autodidaktisch gelernt, 2012 mit KO-Web selbständig gemacht. Über 100 Projekte, Fokus auf Security, Performance, Automatisierung und Qualität. Heute freiberuflich: DevSecOps-Beratung, Schulungen und Softwareentwicklung.
