WordPress lässt sich mit Docker in wenigen Minuten starten. Für einen dauerhaft betriebenen Server reicht ein einfacher WordPress-und-MySQL-Compose-Stack jedoch oft nicht aus.
In diesem Beitrag zeige ich einen produktionsnahen WordPress-Stack, bei dem die einzelnen Komponenten klar voneinander getrennt sind:
- WordPress mit Apache und PHP
- MySQL 8 als Datenbank
- Redis 8 für den Object Cache
- PHP OPcache
- Traefik als Reverse Proxy
- Cloudflare vor Traefik
- korrekte Ermittlung der echten Besucher-IP
- persistente Docker-Volumes
- Healthchecks
- externer WordPress-Cron
- zentrale Konfiguration über
.env
Das Ziel ist kein möglichst kleiner Compose-Stack, sondern eine übersichtliche und reproduzierbare Grundlage für einen selbst betriebenen WordPress-Server.
https://github.com/happylippo/wordpress-stack
Architektur des WordPress Docker Stacks
Ein WordPress-Container ist schnell gestartet. Für einen dauerhaft betriebenen Server reicht ein einfacher WordPress-und-MySQL-Stack jedoch häufig nicht aus.
In diesem Beitrag geht es deshalb um einen produktionsnahen WordPress Docker Stack, bei dem die einzelnen Komponenten klar voneinander getrennt sind:
- WordPress mit Apache und PHP
- MySQL 8 als Datenbank
- Redis 8 als Object Cache
- PHP OPcache
- Traefik als Reverse Proxy
- Cloudflare als vorgeschalteter Proxy
- korrekte Ermittlung der echten Besucher-IP
- persistente Docker-Volumes
- Healthchecks
- externer WordPress-Cron
- zentrale Konfiguration über
.env
Das Ziel ist kein möglichst kleiner Docker-Compose-Stack, sondern eine übersichtliche, reproduzierbare und gut wartbare Grundlage für einen selbst betriebenen WordPress-Server.
Die grundlegende Request-Kette sieht folgendermaßen aus:
Internet
│
▼
Cloudflare
│
▼
Traefik
│
▼
WordPress + Apache + PHP
│
├── MySQL 8
│
└── Redis 8
Cloudflare bildet die äußerste Proxy-Schicht. Danach übernimmt Traefik auf dem Docker-Host das Routing zum WordPress-Container.
WordPress kommuniziert wiederum über ein separates internes Docker-Netzwerk mit MySQL und Redis.
Dadurch müssen weder MySQL noch Redis Ports auf dem Docker-Host veröffentlichen.
Docker Compose als Grundlage
Die Services werden mit Docker Compose verwaltet. Dadurch lässt sich ein großer Teil der Infrastruktur als Code im Git-Repository versionieren.
Der Stack besteht im Wesentlichen aus drei Anwendungsdiensten:
wordpress
mysql
redis
WordPress befindet sich dabei in zwei Docker-Netzwerken:
backend
proxy
Internes Backend-Netzwerk
Das Netzwerk backend dient ausschließlich der Kommunikation zwischen WordPress, MySQL und Redis.
Es ist als internes Docker-Netzwerk definiert.
MySQL und Redis benötigen dadurch keine veröffentlichten Ports wie 3306 oder 6379.
Externes Traefik-Netzwerk
Das Netzwerk proxy existiert unabhängig vom WordPress-Stack und wird auch von Traefik verwendet.
WordPress wird zusätzlich mit diesem Netzwerk verbunden.
Dadurch kann Traefik den Webserver des WordPress-Containers auf Port 80 erreichen, ohne diesen Port direkt auf dem Docker-Host veröffentlichen zu müssen.
WordPress mit eigenem Docker-Image
Anstatt das offizielle WordPress-Image unverändert einzusetzen, verwendet der Stack ein kleines eigenes Dockerfile auf Basis des offiziellen WordPress-Apache-Images.
Das ermöglicht es, zusätzliche Komponenten reproduzierbar in das Image zu integrieren.
In unserem Fall betrifft das insbesondere Apache mod_remoteip.
Der erste Build erfolgt beispielsweise mit:
docker compose up -d --build
Ein erneuter Build ist allerdings nicht bei jedem Containerstart notwendig.
Für einen normalen Start genügt:
docker compose up -d
Wann ist –build notwendig?
Ein neuer Build wird benötigt, wenn sich beispielsweise folgende Dateien ändern:
Dockerfile- das eigene EntryPoint-Script
- Dateien, die mit
COPYin das Image übernommen werden - das verwendete WordPress-Basis-Image
Wird dagegen lediglich die .env geändert, ist normalerweise kein Image-Build erforderlich.
Zentrale Konfiguration über .env
Installationsabhängige Werte sollen nicht fest in der Compose-Datei hinterlegt werden.
Dafür verwendet der Stack eine .env-Datei.
Beispielsweise:
MYSQL_DATABASE=wordpress
MYSQL_USER=wpadmin
MYSQL_PASSWORD=CHANGE_ME
WORDPRESS_MEMORY_LIMIT=256M
WORDPRESS_MAX_MEMORY_LIMIT=512M
REDIS_DATABASE=0
REDIS_MAXMEMORY=256mb
TRAEFIK_DOCKER_NETWORK=proxy
TRAEFIK_ENTRYPOINTS=websecure
TRAEFIK_RULE=Host(`wordpress.example.com`)
Die tatsächliche .env gehört nicht in das Git-Repository.
Stattdessen befindet sich dort eine:
.env.example
Sie erfüllt gleichzeitig zwei Aufgaben: Sie dient als Vorlage für neue Installationen und dokumentiert die verfügbaren Konfigurationsparameter.
Traefik als Reverse Proxy
WordPress veröffentlicht in diesem Aufbau keinen klassischen Host-Port:
ports:
- "8080:80"
Stattdessen erreicht Traefik den WordPress-Container direkt über das gemeinsame Docker-Netzwerk.
Das Routing wird über Docker-Labels am WordPress-Service definiert.
Beispielsweise:
labels:
traefik.enable: "true"
traefik.docker.network: "proxy"
traefik.http.routers.wordpress-app.entrypoints: "websecure"
traefik.http.routers.wordpress-app.rule: "Host(`wordpress.example.com`)"
traefik.http.routers.wordpress-app.tls: "true"
traefik.http.services.wordpress-app.loadbalancer.server.port: "80"
Die eigentlichen Werte werden wiederum aus der .env übernommen.
Traefik-Konfiguration per Environment
Dadurch lassen sich beispielsweise folgende Einstellungen ändern, ohne die Compose-Datei selbst bearbeiten zu müssen:
- Domain
- EntryPoint
- Docker-Netzwerk
- TLS
- Certificate Resolver
- Middlewares
- Service-Port
- Service-Name
Damit kann dieselbe Compose-Konfiguration auf unterschiedlichen Servern eingesetzt werden.
HTTPS hinter Traefik
Die TLS-Terminierung erfolgt bei Traefik.
Intern erreicht Traefik Apache über HTTP.
Ohne zusätzliche Behandlung könnte WordPress deshalb davon ausgehen, dass die Website selbst über HTTP aufgerufen wurde.
Der Stack berücksichtigt daher den Header:
X-Forwarded-Proto
Wenn Traefik dort https übermittelt, wird die HTTPS-Erkennung innerhalb von WordPress entsprechend gesetzt.
Zusätzlich wird:
define('FORCE_SSL_ADMIN', true);
verwendet.
Dadurch erkennt WordPress die externe HTTPS-Verbindung korrekt und der Administrationsbereich wird über HTTPS betrieben.
Echte Besucher-IP hinter Cloudflare und Traefik
Eine Besonderheit entsteht durch die Kombination aus Cloudflare und Traefik.
Die Verbindung läuft schließlich über mehrere Stationen:
Besucher
↓
Cloudflare
↓
Traefik
↓
Apache
↓
WordPress
Ohne zusätzliche Konfiguration sieht Apache nicht unbedingt die IP-Adresse des Besuchers.
Stattdessen kann dort die interne IP-Adresse des Traefik-Containers auftauchen.
Das ist insbesondere für folgende Bereiche problematisch:
- Apache Access Logs
- WordPress Security Plugins
- Login-Schutz
- Rate Limiting
- Audit Logging
- Besucherstatistiken
- Fehleranalyse
Apache mod_remoteip
Deshalb aktiviert das eigene WordPress-Image das Apache-Modul:
mod_remoteip
Cloudflare übermittelt die ursprüngliche Besucher-IP über:
CF-Connecting-IP
Apache kann diesen Header auswerten und die interne Client-IP entsprechend ersetzen.
Nach erfolgreicher Verarbeitung steht die Besucher-IP unter anderem über:
REMOTE_ADDR
für PHP und damit auch für WordPress zur Verfügung.
Warum vertrauenswürdige Proxies notwendig sind
Ein wichtiger Punkt ist die Vertrauensstellung.
Apache darf nicht jedem beliebigen Client erlauben, einfach einen eigenen:
CF-Connecting-IP
Header zu übermitteln und damit seine IP-Adresse selbst festzulegen.
Deshalb akzeptiert der Stack diesen Header nur von explizit als vertrauenswürdig definierten Proxies.
IPv4 und IPv6 für Traefik konfigurieren
Die vertrauenswürdigen Traefik-Adressen werden über .env angegeben.
IPv4 und IPv6 können gemeinsam als kommaseparierte Liste eingetragen werden:
TRUSTED_PROXIES=172.31.191.254,fd00:1:be:a:7001:0:3e:7fff
Beim Start des WordPress-Containers verarbeitet das eigene EntryPoint-Script diese Variable.
Daraus entstehen intern zwei Apache-Direktiven:
RemoteIPTrustedProxy 172.31.191.254
RemoteIPTrustedProxy fd00:1:be:a:7001:0:3e:7fff
CIDR-Netze verwenden
Alternativ können ganze Docker-Netze freigegeben werden.
Beispielsweise:
TRUSTED_PROXIES=172.31.128.0/18,fd00:1:be:a:7001:0:3e:7000/116
Das ist insbesondere dann praktisch, wenn Traefik beim Neuerstellen des Containers eine andere IP-Adresse erhalten kann.
Die Freigabe eines gesamten Netzes vergrößert allerdings den Vertrauensbereich.
Deshalb sollte ein möglichst enges Netzwerk verwendet werden.
Echte IP-Adresse überprüfen
Die Apache-Logs lassen sich direkt über Docker verfolgen:
docker compose logs -f wordpress
Das verwendete Apache-Logformat arbeitet nach der mod_remoteip-Verarbeitung mit %a.
Dort sollte deshalb anschließend die tatsächliche Besucher-IP und nicht lediglich die interne Traefik-IP erscheinen.
Redis 8 als WordPress Object Cache
WordPress erzeugt zahlreiche Datenbankabfragen, deren Ergebnisse sich wiederverwenden lassen.
Hier kommt Redis ins Spiel.
Der Redis-Container ist ausschließlich über das interne Backend-Netzwerk erreichbar:
WordPress
│
▼
redis:6379
WordPress erhält die entsprechenden Verbindungsparameter über seine Konfiguration:
define('WP_REDIS_HOST', 'redis');
define('WP_REDIS_PORT', 6379);
Anschließend kann beispielsweise das WordPress-Plugin Redis Object Cache installiert und aktiviert werden.
Redis-Speicher begrenzen
Redis soll nicht unbegrenzt Arbeitsspeicher belegen.
Deshalb kann ein maximales Speicherbudget definiert werden:
REDIS_MAXMEMORY=256mb
REDIS_MAXMEMORY_POLICY=allkeys-lru
Bei diesem Stack wird Redis als Cache und nicht als primärer Datenspeicher verwendet.
Eine Eviction Policy wie:
allkeys-lru
passt deshalb gut zum Einsatzzweck.
Redis-Persistenz
Zusätzlich verwendet der Stack AOF-Persistenz.
Redis schreibt Änderungen dabei in eine Append-Only-Datei.
Für einen reinen Object Cache ist Persistenz nicht zwingend notwendig. Verlorene Cache-Inhalte können schließlich wieder aufgebaut werden.
Sie kann dennoch verhindern, dass Redis nach jedem Neustart mit einem vollständig kalten Cache beginnt.
PHP OPcache für WordPress
Redis und OPcache werden gelegentlich in einen Topf geworfen, erfüllen aber unterschiedliche Aufgaben.
Redis cached Anwendungsdaten und WordPress-Objekte.
OPcache cached dagegen bereits kompilierten PHP-Bytecode.
Ohne OPcache muss PHP vereinfacht betrachtet bei einem Script immer wieder:
PHP-Datei
↓
parsen
↓
kompilieren
↓
Bytecode ausführen
OPcache hält den kompilierten Bytecode im Arbeitsspeicher.
PHP kann dadurch bei weiteren Requests einen Teil dieser Arbeit überspringen.
OPcache über INI statt .env
Die OPcache-Konfiguration befindet sich bewusst in:
config/php/opcache.ini
und nicht vollständig in der .env.
Eine statische PHP-Laufzeitkonfiguration lässt sich dadurch sauber über Git versionieren.
Das bringt einige Vorteile:
- Änderungen sind nachvollziehbar
- identische Konfiguration auf mehreren Hosts
- einfaches Code-Review
- reproduzierbares Deployment
- übersichtliche PHP-Konfiguration
Überprüfen lässt sich die aktive Konfiguration beispielsweise mit:
docker compose exec wordpress php -i | grep -i opcache
PHP Upload- und Laufzeitlimits
Neben OPcache verwendet der Stack eine weitere PHP-Konfigurationsdatei:
config/php/uploads.ini
Dort können beispielsweise folgende Werte definiert werden:
upload_max_filesize=128M
post_max_size=128M
memory_limit=512M
max_execution_time=300
max_input_vars=5000
Diese Werte lassen sich dadurch unabhängig von WordPress selbst verwalten.
Vorgeschaltete Upload-Limits beachten
Das PHP-Limit ist allerdings nicht zwangsläufig das einzige Limit.
Ein Upload durchläuft bei diesem Aufbau mehrere Komponenten:
Browser
↓
Cloudflare
↓
Traefik
↓
Apache
↓
PHP
↓
WordPress
Wenn eine vorgeschaltete Komponente den Request bereits ablehnt, hilft ein höheres upload_max_filesize in PHP nicht.
Bei Problemen sollten deshalb alle beteiligten Ebenen überprüft werden.
MySQL 8 als WordPress-Datenbank
Die Datenbank läuft in einem separaten MySQL-8-Container.
Die Daten werden über ein Docker-Volume persistent gespeichert.
Einige grundlegende Einstellungen können über .env angepasst werden:
MYSQL_INNODB_BUFFER_POOL_SIZE=256M
MYSQL_MAX_CONNECTIONS=100
Weitere statische Einstellungen befinden sich unter:
config/mysql/custom.cnf
InnoDB Buffer Pool richtig dimensionieren
Der InnoDB Buffer Pool sollte nicht einfach mit einem beliebigen hohen Wert konfiguriert werden.
Die sinnvolle Größe hängt unter anderem ab von:
- verfügbarem RAM
- Größe der WordPress-Datenbank
- Anzahl paralleler Zugriffe
- weiteren Diensten auf dem Docker-Host
MySQL, Redis, PHP und das Betriebssystem teilen sich schließlich denselben physischen Arbeitsspeicher.
Healthchecks für die Container
Ein laufender Container ist nicht automatisch ein funktionierender Dienst.
Deshalb verwendet der Stack Healthchecks.
MySQL Healthcheck
MySQL wird erst als gesund betrachtet, wenn der Datenbankserver tatsächlich Anfragen akzeptiert.
Das verhindert, dass ausschließlich der gestartete Prozess als Erfolg gewertet wird.
Redis Healthcheck
Redis lässt sich sehr einfach mit:
redis-cli ping
überprüfen.
Eine funktionierende Redis-Instanz antwortet mit:
PONG
WordPress Healthcheck
Auch WordPress erhält einen HTTP-basierten Healthcheck.
Damit lässt sich feststellen, ob Apache tatsächlich HTTP-Anfragen verarbeitet.
WordPress Cron über den Docker-Host
Standardmäßig besitzt WordPress keinen klassischen System-Cronjob.
WP-Cron wird stattdessen durch Website-Aufrufe angestoßen.
Das ist komfortabel, hat auf einem produktiven Server aber Nachteile.
Bei wenig Traffic können geplante Aufgaben verspätet ausgeführt werden.
Bei sehr viel Traffic wird dagegen unnötig häufig überprüft, ob Aufgaben anstehen.
WP-Cron deaktivieren
Im Stack lässt sich der interne Mechanismus über .env deaktivieren:
WORDPRESS_DISABLE_WP_CRON=true
Anschließend übernimmt der Docker-Host die regelmäßige Ausführung.
Cronjob alle fünf Minuten
Beispielsweise:
*/5 * * * * docker exec wordpress-app php /var/www/html/wp-cron.php >/dev/null 2>&1
Damit wird wp-cron.php alle fünf Minuten direkt über PHP im WordPress-Container ausgeführt.
Eine Compose-basierte Alternative wäre:
*/5 * * * * cd /opt/wordpress-stack && docker compose exec -T wordpress php /var/www/html/wp-cron.php >/dev/null 2>&1
Der Parameter -T verhindert dabei die Zuweisung eines interaktiven Terminals.
Warum der direkte PHP-Aufruf sinnvoll ist
Der Cronjob muss dadurch keinen HTTP-Aufruf über die komplette Proxy-Kette durchführen.
Statt:
Host
↓
Cloudflare
↓
Traefik
↓
Apache
↓
WordPress
läuft der Job direkt im Container:
Host Cron
↓
Docker
↓
PHP
↓
wp-cron.php
Der Cronjob funktioniert damit unabhängig von DNS, Cloudflare und der externen Erreichbarkeit der Website.
Persistente Docker-Volumes
Container selbst sollten grundsätzlich als austauschbar betrachtet werden.
Die wichtigen Daten befinden sich deshalb außerhalb des eigentlichen Container-Dateisystems in Docker-Volumes.
Der Stack verwendet persistente Speicherbereiche für:
WordPress
MySQL
Redis
Dadurch können Container neu erstellt werden, ohne automatisch die Anwendungsdaten zu verlieren.
Docker-Volumes sind keine Backups
Persistenz und Backup sind zwei verschiedene Dinge.
Wenn eine Datenbank beschädigt oder versehentlich gelöscht wird, kann auch das persistente Volume beschädigte oder gelöschte Daten enthalten.
Für WordPress sollten deshalb mindestens separat gesichert werden:
- MySQL-Datenbank
wp-content- relevante Konfigurationsdateien
Zusätzlich sollte regelmäßig getestet werden, ob sich diese Backups tatsächlich wiederherstellen lassen.
WordPress Docker Stack aktualisieren
Neue Basis-Images lassen sich zunächst herunterladen:
docker compose pull
Da der WordPress-Service ein eigenes Image verwendet, sollte dessen Basis-Image anschließend ebenfalls neu eingebunden werden:
docker compose build --pull wordpress
docker compose up -d
Nicht mehr verwendete Images können danach entfernt werden:
docker image prune
Vor größeren Updates sollte ein getestetes Backup vorhanden sein.
Sicherheitskonzept des Stacks
Docker allein macht eine WordPress-Installation nicht automatisch sicher.
Der Stack versucht deshalb, unnötige Angriffsflächen bereits in der Architektur zu vermeiden.
Keine öffentlichen Datenbankports
MySQL und Redis veröffentlichen keine Ports auf dem Host.
Ein externer Client kann deshalb nicht direkt über:
3306
oder:
6379
auf diese Dienste zugreifen.
Internes Backend-Netzwerk
MySQL und Redis befinden sich ausschließlich im internen Backend-Netzwerk.
Nur WordPress benötigt zusätzlich Zugriff auf das externe Traefik-Netzwerk.
Secrets nicht in Git speichern
Datenbankpasswörter befinden sich in der lokalen .env.
Diese Datei wird über .gitignore vom Repository ausgeschlossen.
Im Repository liegt lediglich .env.example ohne produktive Zugangsdaten.
Proxy-Header nicht blind vertrauen
Auch die Real-IP-Konfiguration ist Teil des Sicherheitskonzepts.
CF-Connecting-IP wird nicht pauschal akzeptiert.
Nur explizit definierte Traefik-Proxies dürfen die Client-IP beeinflussen.
Das verhindert, dass ein beliebiger Client einfach seine gewünschte IP-Adresse über einen HTTP-Header vorgibt.
Was der Stack nicht ersetzt
Auch ein sauber aufgebauter Container-Stack ersetzt kein vollständiges Betriebskonzept.
Für einen produktiven WordPress-Server gehören zusätzlich beispielsweise dazu:
- automatische Backups
- regelmäßige Restore-Tests
- Monitoring
- zentralisiertes Logging oder Log-Rotation
- WordPress-Core-Updates
- Plugin- und Theme-Updates
- Docker-Host-Updates
- Security-Patches
- Firewall-Regeln
- SSH-Härtung
- Speicherplatzüberwachung
- RAM- und CPU-Monitoring
- Schutz vor Brute-Force-Angriffen
Besonders wichtig sind Backups.
Ein Backup sollte nicht nur existieren. Es sollte auch nachweislich wiederherstellbar sein.
Fazit zum WordPress Docker Stack
Der entscheidende Vorteil dieses Aufbaus ist die klare Trennung der Verantwortlichkeiten.
Die komplette Request-Kette sieht letztlich so aus:
Cloudflare
↓
Traefik
↓
WordPress / Apache / PHP
↓
┌───────────────┐
│ │
▼ ▼
MySQL 8 Redis 8
Cloudflare bildet die vorgelagerte Proxy-Schicht.
Traefik übernimmt Routing und TLS.
Apache liefert WordPress über HTTP aus und stellt mit mod_remoteip die tatsächliche Client-IP wieder her.
PHP führt WordPress aus und profitiert von OPcache.
Redis beschleunigt WordPress durch einen persistenten Object Cache.
MySQL übernimmt die dauerhafte Speicherung der Anwendungsdaten.
Docker Compose beschreibt, wie diese Komponenten miteinander verbunden werden.
Damit wird WordPress nicht mehr als einzelner Server betrachtet, auf dem Apache, PHP, MySQL und Redis irgendwie gemeinsam installiert wurden.
Stattdessen entsteht eine klar definierte Infrastruktur aus einzelnen Diensten.
Konfigurationen liegen nachvollziehbar im Git-Repository, installationsabhängige Werte kommen aus der .env und interne Dienste werden nicht unnötig nach außen exponiert.
Genau diese Kombination macht den Stack zu einer soliden Grundlage für einen selbst betriebenen WordPress-Server.
Weiterführende Links
https://github.com/happylippo/wordpress-stack
