Vor drei Jahren hat mich ein Kunde angerufen, dessen WordPress-Shop „einfach zu langsam" war. Er wollte einen neuen Server kaufen — mehr CPU, mehr RAM, die doppelte Leistung. Ich habe erst gemessen, bevor er Geld ausgegeben hat: Die Startseite brauchte 2,8 Sekunden bis zum ersten Byte (TTFB). Der Server war dabei zu 90 Prozent im Leerlauf. Das Problem war keine Hardware, sondern eine Konfiguration, die seit der Installation niemand angefasst hatte: PHP-FPM lief mit Standardwerten, opcache war aus, und nginx komprimierte nichts. Zwei Stunden später stand die Seite bei 180 Millisekunden TTFB — auf demselben Server, ohne einen Cent Investition.

Dieser Artikel zeigt das komplette Vorher/Nachher-Tuning mit den konkreten Werten: erst messen und verstehen, wo die Zeit hinfließt, dann PHP-FPM richtig dimensionieren, opcache einschalten und die nginx-Direktiven erklären, die niemand mehr hinterfragt.

Erst messen: Wo fließt die Zeit hin?

Vor jeder Änderung steht die Messung. Wer blind Direktiven umstellt, ratet. Das wichtigste Werkzeug ist curl mit write-out — das habe ich im curl-Artikel schon gezeigt, hier kommt es zum Einsatz:

curl -sS -o /dev/null -w \
  "DNS: %{time_namelookup}s\nTCP: %{time_connect}s\nTLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n" \
  https://example.com

Die Ausgabe bei der langsamen WordPress-Site von damals:

DNS:   0.041s
TCP:   0.058s
TLS:   0.121s
TTFB:  2.390s   ← hier liegt das Problem
Total: 2.640s

Die Lektion: DNS, TCP und TLS zusammen brauchen 220 Millisekunden — das ist normal. Die restlichen 2,4 Sekunden sind TTFB: die Zeit, die der Server braucht, bis er das erste Byte der Antwort schickt. Bei einer PHP-Seite heißt das: nginx wartet auf PHP-FPM, PHP-FPM wartet auf PHP, PHP wartet auf die Datenbank. Genau dort musste das Tuning ansetzen, nicht am Serverkauf.

Latenz-Wasserfall vorher/nachher: DNS, TLS, TTFB und Transfer — TTFB von 2,4 s auf 120 ms, Gesamt von 2,8 s auf 392 ms

PHP-FPM: die Worker richtig berechnen

PHP-FPM hält eine Anzahl von Worker-Prozessen bereit, die PHP-Skripte ausführen. Die wichtigste Einstellung ist pm.max_children — die maximale Zahl gleichzeitiger Worker. Zu wenige Worker: Anfragen warten in der Warteschlange, die Seite wird langsam. Zu viele Worker: Der Server geht in die Knie, weil jeder Worker RAM frisst und das System zu swappen beginnt.

Die Berechnung ist einfache Mathematik:

# Ein PHP-Worker braucht bei WordPress typisch 30–60 MB RAM
# Server: 4 GB RAM, 3 GB für PHP reserviert
# 3 GB / 50 MB = 60 Worker

pm = dynamic
pm.max_children = 60
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 15
pm.max_requests = 1000

Das Modell dynamic startet mit 10 Workern und skaliert nach Bedarf zwischen 5 und 15 ungenutzten. Wer Speicher sparen will, nimmt ondemand — Worker starten nur, wenn eine Anfrage kommt, und sterben nach pm.process_idle_timeout. Für stark frequentierte Seiten ist static mit festen Workern am schnellsten, weil kein Start/Stopp-Overhead anfällt — kostet aber permanent RAM. Auf einem 2-GB-VPS mit WordPress starte ich mit dynamic und 30 Workern, messe, und justiere.

Die RAM-Rechnung ist übrigens die Stelle, an der der bitcalc IOPS-Rechner ins Spiel kommt — nicht für die Worker, sondern für den Unterbau: Wenn dein Server anfängt zu swappen, sinken die IOPS dramatisch, und jede PHP-Anfrage wird zur Geduldsprobe. Wer Logs, Datenbank oder Sessions auf einer langsamen Platte liegen hat, sieht im IOPS-Rechner sofort, warum die Werte nicht reichen — und ob eine SSD die Antwort ist, bevor man teurere Hardware kauft.

pm.max_requests: gegen das stille Speicherleck

PHP-Skripte leaken Speicher. Nicht immer absichtlich, aber jede Library, jedes Plugin, jeder fehlerhafte Aufruf hinterlässt ein paar Kilobyte. Über Wochen wächst ein Worker so von 40 MB auf 200 MB — und niemand merkt es, bis der Server swappt.

Die Lösung ist pm.max_requests = 1000: Nach 1000 bearbeiteten Anfragen wird der Worker sauber beendet und ein frischer gestartet. Das kostet nichts (der Neustart dauert Millisekunden) und verhindert die klassische „Site wird über Wochen immer langsamer"-Kurve. Bei WordPress mit seinen vielen Plugins ist das die wichtigste vorbeugende Einstellung überhaupt.

opcache: der vergessene Turbo

PHP kompiliert jedes Skript bei jedem Aufruf neu in Bytecode. Bei WordPress mit 200 Dateien pro Request ist das ein erheblicher Teil der Antwortzeit. Der OPcache speichert den kompilierten Bytecode im RAM — danach überspringt PHP die Kompilierung komplett.

Das Erschreckende: Auf vielen Servern ist opcache schlicht aus oder auf Default-Werten, die für kleine Skripte gedacht sind. So sieht ein sinnvolles Setup für WordPress aus:

; /etc/php/8.3/fpm/conf.d/10-opcache.ini
opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 2

Die beiden Zahlen, die bei WordPress fast immer zu klein sind: memory_consumption (128 MB statt 64) und max_accelerated_files (10.000 statt 4.000). Wer wissen will, ob der Cache voll ist, fragt die Status-Seite ab:

php -r 'var_dump(opcache_get_status()["memory_usage"]);'

Wenn used_memory nahe an free_memory liegt, erhöhst du den Wert. In meinem Kundenfall war opcache komplett aus — allein das Einschalten hat die TTFB halbiert.

nginx: die Direktiven ohne Magie

Jetzt der nginx-Teil. Drei Dinge machen den Unterschied bei PHP-Sites: Worker richtig setzen, statische Dateien entlasten und gzip aktivieren. Die Direktiven im Überblick — als Tabelle mit ehrlicher Wirkung statt Mythen:

nginx- und PHP-FPM-Konfigurationstabelle: Direktive, Wert und Wirkung — worker_processes, keepalive, gzip, pm.max_children, opcache

Drei Dinge aus der Tabelle verdienen eine eigene Erklärung:

worker_processes = auto startet einen Worker pro CPU-Kern. Das ist die richtige Wahl für fast alle Fälle — nginx-Worker sind I/O-lastig und profitieren von mehr Kernen, aber mehr Worker als Kerne bringen nichts. worker_connections = 1024 ist keine Zauberzahl: Sie begrenzt die gleichzeitigen Verbindungen pro Worker, und bei modernem Keepalive reicht sie für die meisten Sites locker. Wer glaubt, hier „höher = besser" gelte, erzeugt nur unnötigen Speicherverbrauch.

gzip on ist der unsichtbare Beschleuniger: HTML, CSS und JSON werden vor dem Transfer komprimiert und sparen typischerweise 60 bis 80 Prozent Volumen. Das schlägt direkt auf die Transferzeit durch — und wer vorher den bitcalc Downloadzeit-Rechner benutzt hat, weiß, wie viel eine Leitung mit weniger Bytes gewinnt. Für API-Antworten im JSON-Format gilt dasselbe: gzip an, und die Antworten kommen schneller an, als der Client sie verarbeiten kann.

keepalive 65 und keepalive_requests sorgen dafür, dass der Client die TCP-Verbindung für mehrere Requests wiederverwendet. Ein Browser, der 80 statische Dateien lädt, spart so den TLS-Handshake pro Datei — bei HTTP/2 ist das automatisch anders organisiert, aber für HTTP/1.1-Clients und API-Consumer ist Keepalive der Unterschied zwischen 80 Handshakes und einem.

Der PHP-FPM-Status: das Dashboard, das niemand kennt

PHP-FPM hat eine eingebaute Status-Seite, die dir in Echtzeit zeigt, was die Worker tun:

# In der Pool-Config aktivieren
pm.status_path = /status

# Im nginx-Serverblock freigeben
location = /status {
    access_log off;
    allow 127.0.0.1;
    deny all;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    include fastcgi_params;
}

Dann liefert curl http://127.0.0.1/status?full Werte wie active processes, max children reached und max listen queue. Die wichtigste Kennzahl: Wenn max children reached regelmäßig über 0 liegt, sind zu wenige Worker konfiguriert — Anfragen warten, die Seite wird langsam. Und wenn die Warteschlange (listen queue) wächst, obwohl die Worker frei sind, stimmt etwas mit der Verbindung zwischen nginx und FPM nicht. Diese eine Seite hat mir mehr Tuning-Abende erspart als jede Doku.

Die Sicherheitsschraube: Basic Auth mit htpasswd

Ein Nebenschauplatz, der beim Tuning oft vergessen wird: Staging-Umgebungen und Admin-Bereiche, die nicht öffentlich sein sollen, bekommst du bei nginx mit zwei Zeilen abgesichert:

# htpasswd-Datei erzeugen (Passwort mit dem bitcalc Passwort-Generator generieren)
htpasswd -c /etc/nginx/.htpasswd admin

# Im nginx-Serverblock
location /staging {
    auth_basic "Restricted";
    auth_basic_user_file /etc/nginx/.htpasswd;
}

Das ist Basic Auth — derselbe Mechanismus wie bei curl -u aus dem curl-Artikel: Benutzer und Passwort Base64-kodiert im Header, also nur über HTTPS verwenden. Für die Passwort-Generierung nutze ich den bitcalc Passwort-Generator — 32 Zeichen, alle Klassen, und ab in den Passwort-Manager.

Das Ergebnis: 2,8 Sekunden auf 392 Millisekunden

Zusammengefasst hat die Tuning-Session bei der WordPress-Site des Kunden so ausgesehen:

  • opcache einschalten (war aus): TTFB von 2,4 s auf 1,1 s — der größte Einzelgewinn.
  • PHP-FPM von Default auf dynamic/60 Worker mit max_requests=1000: TTFB auf 320 ms, keine Warteschlange mehr.
  • gzip on und Keepalive: Transferzeit von 250 ms auf 180 ms.
  • opcache auf 128 MB / 10.000 Dateien: TTFB auf 120 ms stabilisiert.

Die Seite lädt jetzt in rund 400 Millisekunden komplett, TTFB bei 120 bis 180 ms. Der Kunde hat keinen neuen Server gekauft. Die alte Hardware reicht — und tut es heute noch.

Vor dem Serverkauf messen: Eine langsame Website ist selten ein Hardware-Problem, sondern meistens eine Konfigurations- oder Datenbank-Sache. Erst mit curl messen (TTFB!), dann PHP-FPM dimensionieren, opcache einschalten, gzip aktivieren — und für den Unterbau prüfen, ob Logs und Datenbank auf schnellen Disks liegen: Der bitcalc IOPS-Rechner zeigt, welche Leistung du wirklich brauchst.

Fazit

Webserver-Tuning ist keine Magie, sondern Messen und Rechnen. Die drei größten Hebel bei PHP-Sites sind immer dieselben: opcache einschalten und richtig dimensionieren, PHP-FPM-Worker an den RAM anpassen statt Defaults zu übernehmen, und gzip plus Keepalive in nginx aktivieren. Wer dazu den Latenz-Wasserfall kennt, weiß vor jeder Änderung, wo die Zeit hinfließt — und verschwendet keine Nacht mit Direktiven, die nichts bringen.

Der Kunde von damals ruft übrigens noch heute an, wenn seine Site „langsam" ist. Meistens ist es ein Plugin-Update, ein voller Cache oder eine Datenbank ohne Index. Die Hardware war nie das Problem — und das ist die beste Nachricht, die ein Admin bekommen kann.