Lehre für künftige Updates: Bei Image-Updates immer den ganzen Stack zusammen recreaten, z.B. docker compose up -d --force-recreate statt nur PHP. Oder im Workflow nach wordpress-image-Rebuild zusätzlich docker restart <site>-nginx pro Site.
1. Filesystem-Layout
Bash
/mnt/data/ ← alles auf separater Disk (gut!)
│
├── docker/ ← Docker daemon data-root (volumes, images)
│ └── volumes/
│ ├── nginx-proxy_certs/ ← Let's Encrypt Zertifikate (zentral)
│ ├── nginx-proxy_vhost/ ← per-vhost nginx-Snippets
│ ├── nginx-proxy_html/ ← ACME http-01 challenges
│ ├── fastpage_wp_data/ ← WordPress files (wp-content, core, …)
│ ├── fastpage_db_data/ ← MariaDB datadir
│ ├── fastpage_nginx_cache/ ← FastCGI-Cache
│ └── …gleiches Muster pro Site
│
├── sites/ ← pro Site ein Verzeichnis
│ ├── fastpage/
│ │ ├── docker-compose.yml ← Stack-Definition
│ │ ├── .env (600) ← DB_PASS, DB_ROOT_PASS
│ │ ├── .credentials (600) ← Admin-PW, DB-Creds (Klartext!)
│ │ └── config/
│ │ ├── nginx.conf ← site-spezifische server{}
│ │ └── php.ini ← upload-size, opcache, …
│ ├── buezerpage/ ← gleiche Struktur
│ ├── gwdev/
│ ├── test/ test2/ test3-gwdev/
│ └── (static-sites haben zusätzlich:)
│ ├── public/ ← Web-Root (HTML/PHP/CSS)
│ └── config/msmtprc (600) ← SMTP-Creds
│
├── logs/ ← auf Host, damit fail2ban liest
│ └── <site>/nginx/
│ ├── access.log
│ └── error.log
│
├── nginx-proxy/ ← der Reverse-Proxy-Stack
│ └── docker-compose.yml
│
├── nginx-image/ ← Build-Context: gw/nginx:cache-purge
│ ├── Dockerfile ← alpine + nginx 1.26.2 + ngx_cache_purge
│ └── nginx.conf ← http{} mit FastCGI-Cache-Zone
│
├── wordpress-image/ ← Build-Context: gw/wordpress:php8.3-fpm
│ └── Dockerfile ← wordpress:php8.3-fpm + wp-cli
│
├── php-static-image/ ← Build-Context: gw/php:static-mail
│ ├── Dockerfile ← php:8.3-fpm-alpine + msmtp + opcache
│ └── sendmail.ini
│
├── fail2ban-config/ ← Host-fail2ban configs
│ ├── wordpress-login.conf
│ ├── wordpress-xmlrpc.conf
│ ├── wordpress.local
│ ├── docker-user.conf
│ └── logrotate-nginx
│
├── new-site.sh ← Provisioner: WordPress-Stack
└── new-static-site.sh ← Provisioner: Static/PHP-Stack2. Netzwerk-Topologie (Container-Ebene)
Bash
INTERNET
│
┌──────┴──────┐
│ Ports 80, │
│ 443 │ ← einzige offene Ports
└──────┬──────┘
│
╔═══════════════▼═══════════════════╗
║ Docker-Netz: nginx-proxy_default ║
║ ║
║ ┌───────────────────────────┐ ║
║ │ nginx-proxy │ ║ liest VIRTUAL_HOST
║ │ (nginxproxy/nginx-proxy)│◄───╫── aus Container-Envs
║ │ │ ║ via docker.sock (ro)
║ │ + acme-companion │ ║ → generiert vhosts
║ │ (Let's Encrypt) │ ║ → holt Certs
║ └───────────┬───────────────┘ ║
║ │ ║
║ (HTTP, intern) ║
║ │ ║
║ ┌───────────┼─────────────────┐ ║
║ │ │ │ ║
║ ▼ ▼ ▼ ║
║ fastpage- buezerpage- …gwdev- ║
║ nginx nginx nginx ║
╚══╪═══════════╪═════════════╪══════╝
│ │ │
╔═════════╪═══╗ ╔═════╪══════╗ ╔════╪═══════╗
║ fastpage_ ║ ║ buezerpage_║ ║ gwdev_ ║
║ internal ║ ║ internal ║ ║ internal ║ ← isolierte
║ ║ ║ ║ ║ ║ Netze pro Site
║ ┌─────────┐ ║ ║ ┌────────┐ ║ ║ ┌────────┐ ║
║ │ php │ ║ ║ │ php │ ║ ║ │ php │ ║
║ │ (fpm) │ ║ ║ │ │ ║ ║ │ │ ║
║ ├─────────┤ ║ ║ ├────────┤ ║ ║ ├────────┤ ║
║ │ db │ ║ ║ │ db │ ║ ║ │ db │ ║ ← keine Ports
║ │(mariadb)│ ║ ║ │ │ ║ ║ │ │ ║ nach aussen
║ ├─────────┤ ║ ║ ├────────┤ ║ ║ ├────────┤ ║
║ │ redis │ ║ ║ │ redis │ ║ ║ │ redis │ ║
║ └─────────┘ ║ ║ └────────┘ ║ ║ └────────┘ ║
╚═════════════╝ ╚════════════╝ ╚════════════╝
(analog für test, test2, test3-gwdev)Wichtige Eigenschaft: Jeder nginx-Container hängt in zwei Netzen:
<site>_internal→ erreicht php, db, redis (private)nginx-proxy_default→ erreichbar vom Reverse Proxy
php/db/redis hängen nur im internen Netz. Damit kann keine Site die DB einer anderen sehen.
3. Request-Flow: ein HTTP-Request
Bash
User Browser
│
│ GET https://fastpage.ch/
▼
┌────────────────────────────────────────┐
│ nginx-proxy (Container) │
│ ───────────────────────── │
│ • TLS-Terminierung (Cert aus │
│ nginx-proxy_certs Volume) │
│ • Routing nach Host-Header: │
│ fastpage.ch → fastpage-nginx │
│ • setzt X-Forwarded-For, X-Real-IP │
└──────────────────┬─────────────────────┘
│ HTTP (plain) im
│ nginx-proxy_default Netz
▼
┌────────────────────────────────────────┐
│ fastpage-nginx (Container) │
│ ───────────────────────── │
│ • real_ip_header X-Forwarded-For │
│ • Security-Header, Cache-Control │
│ • try_files: statisch direkt │
│ • WebP/AVIF-Rewrite │
│ • Skip-Cache-Logik (POST, wp-admin, │
│ logged-in cookies) │
│ • FastCGI-Cache HIT? → direkt antw. │
└────┬───────────────────────┬───────────┘
│ (HIT) │ (MISS)
│ ▼
│ ┌────────────────────┐
│ │ fastpage-php (fpm) │
│ │ ──────────────── │
│ │ • WordPress │
│ │ • WP_REDIS_HOST= │
│ │ redis │
│ └───┬──────────┬─────┘
│ │ │
│ ▼ ▼
│ ┌──────────┐ ┌──────────┐
│ │ db │ │ redis │
│ │(mariadb) │ │ (object │
│ │ │ │ cache) │
│ └──────────┘ └──────────┘
│ │
│ │ Response gerendert
│ │ → in nginx-Cache geschrieben
▼ ▼
Antwort an nginx-proxy → TLS → Browser
Logs:
fastpage-nginx → /var/log/nginx/{access,error}.log
(Bind-Mount auf /mnt/data/logs/fastpage/nginx/)
│
▼
┌────────────────────────┐
│ Host: fail2ban │
│ ───────────── │
│ Jails: │
│ • wordpress-login │
│ • wordpress-xmlrpc │
│ → IP-Ban via iptables │
└────────────────────────┘4. Provisioning-Flow: new-site.sh
Bash
Operator: bash /mnt/data/new-site.sh
│
├─► fragt: SITE_NAME, DOMAIN, ADMIN_EMAIL
│
├─► Validierung (regex, kein Overwrite)
│
├─► generiert: DB_PASS, DB_ROOT_PASS, WP_PASS (openssl rand)
│
├─► mkdir /mnt/data/sites/<name>/{config,}
│ /mnt/data/logs/<name>/nginx/
│
├─► schreibt heredocs:
│ .env (chmod 600)
│ docker-compose.yml
│ config/nginx.conf
│ config/php.ini
│
├─► docker compose up -d
│ ├─ pullt mariadb:10.11, redis:alpine
│ └─ verwendet lokale Images gw/wordpress:php8.3-fpm,
│ gw/nginx:cache-purge
│
├─► wartet bis /var/www/html/wp-config.php existiert
│ (WordPress-Entrypoint kopiert Core + erzeugt config)
│
├─► WP-CLI Bootstrap (~25 wp Commands):
│ • wp core install (mit ADMIN_EMAIL, WP_PASS)
│ • Sprache de_DE, Timezone Europe/Zurich
│ • Comments aus, Avatars aus
│ • Permalinks /%postname%/
│ • Dummy-Posts/Themes/Plugins löschen
│ • redis-cache + nginx-helper installieren & aktivieren
│ • webp-converter-for-media installieren (nicht aktivieren)
│
├─► nginx-proxy bemerkt den neuen Container via docker.sock
│ → schreibt vhost-Snippet
│ → reloaded
│ acme-companion bemerkt LETSENCRYPT_HOST
│ → holt Cert via HTTP-01 challenge
│
├─► schreibt .credentials (chmod 600)
│
└─► gibt Login-URL + PW ausBei Fehler greift der ERR/INT/TERM-Trap: docker compose down -v + rm -rf $SITE_DIR. Kein Müll bleibt liegen.
5. Image-Hierarchie
Bash
alpine:3.19
│
└──► gw/nginx:cache-purge (lokal gebaut)
• nginx 1.26.2
• ngx_cache_purge 2.3
• http{}-Block mit FastCGI-Cache-Zone "WORDPRESS"
wordpress:php8.3-fpm (upstream)
│
└──► gw/wordpress:php8.3-fpm (lokal gebaut)
• + wp-cli
php:8.3-fpm-alpine (upstream)
│
└──► gw/php:static-mail (lokal gebaut)
• + msmtp (als sendmail-Replacement)
• + opcache
• sendmail.ini
Verwendung:
─────────────────────────────────────────────────────
WordPress-Sites: gw/wordpress:php8.3-fpm
+ gw/nginx:cache-purge
+ mariadb:10.11
+ redis:alpine
Static/PHP-Sites: gw/php:static-mail
+ gw/nginx:cache-purge
(keine DB, kein Redis)
Reverse Proxy: nginxproxy/nginx-proxy
+ nginxproxy/acme-companion6. Volume-Mounts pro WordPress-Site (Detail)
Bash
Container: fastpage-nginx
─────────────────────────
fastpage_wp_data → /var/www/html (RO) ← PHP-Files lesen
./config/nginx.conf → /etc/nginx/conf.d/default.conf
fastpage_nginx_cache → /var/cache/nginx ← FastCGI-Cache
/mnt/data/logs/fastpage/ → /var/log/nginx ← für fail2ban
Container: fastpage-php
─────────────────────────
fastpage_wp_data → /var/www/html (RW) ← Uploads schreiben
./config/php.ini → /usr/local/etc/php/conf.d/custom.ini
Container: fastpage-db
─────────────────────────
fastpage_db_data → /var/lib/mysql
Container: fastpage-redis
─────────────────────────
(kein Volume — Cache, darf weg)Schlüssel-Idee: wp_data ist shared zwischen php (rw) und nginx (ro). Nginx serviert statisch direkt; PHP-Requests gehen via FastCGI an php:9000.
7. Wer-spricht-mit-wem-Matrix
| Von ↓ / Nach → | nginx-proxy | site-nginx | site-php | site-db | site-redis | Host |
|---|---|---|---|---|---|---|
| Internet | ✅ :80/:443 | ❌ | ❌ | ❌ | ❌ | ❌ |
| nginx-proxy | — | ✅ :80 | ❌ | ❌ | ❌ | docker.sock (ro) |
| site-nginx | ❌ | — | ✅ :9000 | ❌ | ❌ | logs (bind) |
| site-php | ❌ | ❌ | — | ✅ :3306 | ✅ :6379 | ❌ |
| andere site | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |