Skip to content

FrankenPHP + Caddy via Docker Compose (Standard) ​

Dies ist das produktive Setup für Staging und Production. Caddy übernimmt Reverse-Proxy + Auto-SSL für alle Domains; PHP-Workloads (Filament, Kirby, Livewire-B2B) laufen als FrankenPHP-Container im Worker-Mode. JS-Workloads (Nuxt, Vitepress, Storybook-Output) laufen in Bun- bzw. nginx-Containern. Verwaltet wird alles über docker-compose.yml-Dateien in home/bengel/stacks/, versioniert in Git. Portainer dient nur als Cockpit (Logs, Restart, Shell), nicht als Source-of-Truth.

Server-Layout ​

Es gibt zwei klar getrennte Hierarchien auf dem Host:

/home/bengel/stacks/                ← Compose + .env + persistente Daten
├── proxy/                          (caddy + Caddyfile)
├── mgmt/                           (portainer, matomo, matomo-db)
├── git/                            (forgejo+db, woodpecker+agent)
└── apps/
    ├── docker-compose.yml          ← referenziert /var/www/<service>/
    ├── penpot-compose.yml          (Sub-Compose für Penpot)
    ├── .env                        (Passwörter, Keys — NICHT in Git)
    ├── apps_db_data/               ⚠ Volume — Backup!
    ├── apps_redis_data/
    ├── meili_data/                 ⚠
    ├── opensearch_data/            ⚠
    ├── ollama_data/
    ├── penpot_postgres/            ⚠
    └── penpot_assets/              ⚠

/var/www/                           ← Source-Code pro Service (Webroot)
├── admin/                          ← Filament-PIM (Laravel + Filament 5)
├── cms/                            ← Kirby CMS + GraphQL
├── b2b/                            ← Livewire-Händlerportal
├── staging/                        ← Nuxt 4 Marketing-Frontend
├── docs/                           ← VitePress
├── storybook/                      ← statisches Storybook-Build
├── frontend/, portal/              ← Legacy, bleiben bis Cut-Over
├── shared/                         ← Symlink-Targets / Schemas
├── design/, project/, tracking/    ← (Service-Volumes — nicht Code)
└── jeder Ordner: bengel:www-data 2775 (setgid, group-writable)

Warum diese Trennung?

  • /var/www/<service>/ ist die Konvention des Servers (bengel:www-data 2775). Da liegen Code, Dockerfiles, .env der einzelnen Apps.
  • home/bengel/stacks/<stack>/ enthält nur die Orchestrierung — docker-compose.yml, gemeinsame Daten-Volumes (DB, Cache, Search-Indizes).
  • Build-Context im Compose zeigt auf /var/www/<service>/ → das Image wird direkt aus dem Webroot gebaut, kein Doppel-Verzeichnis mit dem ganzen Source nötig.

Warum dieser Stack? ​

KomponenteVorteil
CaddyAuto-SSL (Let's Encrypt + ZeroSSL fallback), HTTP/3 out-of-the-box, deklaratives Caddyfile statt Nginx-Configs
FrankenPHP + OctanePHP bleibt im Speicher — kein Framework-Boot pro Request, ~2-5× schneller als php-fpm
Docker Compose pro Stackklare Trennung (proxy / mgmt / git / apps), Stack einzeln neu starten/deployen/sichern
Git-based Composejede Änderung im Diff sichtbar, Rollback per git revert, DR = git clone + Volumes-Restore
Portainer (read/inspect)Logs, Stats, Container-Shell ohne SSH; aber niemals Stack im Portainer-UI editieren — immer im Repo
Externes Docker-Netz gatewayCaddy spricht jeden Container per Name an, ohne Port-Mapping

1. Server-Vorbereitung (einmalig) ​

bash
# OS aktualisieren
apt update && apt upgrade -y

# Docker
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker

# Deploy-User (kein Passwort, nur SSH-Key)
adduser --disabled-password --gecos "" bengel
usermod -aG docker bengel
mkdir -p /home/bengel/.ssh
echo "ssh-ed25519 AAAA... dev-laptop" >> /home/bengel/.ssh/authorized_keys
chown -R bengel:bengel /home/bengel/.ssh
chmod 700 /home/bengel/.ssh && chmod 600 /home/bengel/.ssh/authorized_keys

# /var/www/ existiert ohnehin — sicherstellen, dass setgid + group write für www-data:
chown -R bengel:www-data /var/www
find /var/www -type d -exec chmod 2775 {} \;
find /var/www -type f -exec chmod 0664 {} \;

# Firewall: 80/443 öffentlich, 9443/9000 (Portainer) nur für Admin-IPs
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80,443/tcp
ufw allow 443/udp                            # HTTP/3
ufw allow from <admin-ip-1> to any port 9443 proto tcp
ufw allow from <admin-ip-1> to any port 9000 proto tcp
ufw enable

WARNING

Portainer niemals offen ins Internet hängen. Entweder Firewall-Whitelist (oben), oder besser: per Tailscale/Wireguard-VPN, dann nur über tailscale0-Interface erreichbar.

2. Externes Docker-Netz + Stacks ziehen ​

bash
su - bengel

# Externes Netz, an dem alle Stacks hängen
docker network create gateway

# Stack-Configs holen (heute: aus dem Infrastructure-Repo, perspektivisch
# eigener `matev/server-stacks`-Repo)
mkdir -p ~/stacks
cd ~/stacks
# Variante A: per Submodule-Subdir aus dem Hauptrepo
rsync -av <dev-laptop>:/path/to/matev_2026/home/bengel/stacks/ ./
# Variante B: separates Repo, sobald Forgejo stabil
# git clone https://git.matev.eu/matev/server-stacks.git .

.env-Dateien für jeden Stack lokal erzeugen — Werte aus dem Passwort-Manager, nicht in Git:

bash
cp ~/stacks/mgmt/.env.example ~/stacks/mgmt/.env && nano ~/stacks/mgmt/.env
cp ~/stacks/git/.env.example  ~/stacks/git/.env  && nano ~/stacks/git/.env
cp ~/stacks/apps/.env.example ~/stacks/apps/.env && nano ~/stacks/apps/.env

3. Stack proxy/ — Caddy ​

Aktueller ~/stacks/proxy/Caddyfile (zentrale Routing-Tabelle):

caddyfile
{
    email bengel@bengel.digital
    auto_https off            # auf "on" stellen sobald DNS produktiv zeigt
    servers { trusted_proxies static private_ranges }
}

# apps/
http://admin.matev.eu     { reverse_proxy admin:80 }
http://cms.matev.eu       { reverse_proxy cms:80 }
http://b2b.matev.eu       { reverse_proxy b2b:80 }
http://staging.matev.eu   { reverse_proxy staging:3000 }
http://storybook.matev.eu { reverse_proxy storybook:80 }
http://docs.matev.eu      { reverse_proxy docs:3000 }
http://design.matev.eu    { reverse_proxy penpot-frontend:80 }
http://project.matev.eu   { reverse_proxy taiga-gateway:80 }

# mgmt/
http://analytics.matev.eu { reverse_proxy matomo:80 }
# Portainer ist NICHT hier — direkt https://<server-ip>:9443

# git/
http://git.matev.eu       { reverse_proxy forgejo:3000 }
http://ci.matev.eu        { reverse_proxy woodpecker:8000 }

Hochfahren:

bash
cd ~/stacks/proxy
docker compose up -d
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

INFO

Sobald DNS auf den Server zeigt, auto_https off entfernen — Caddy holt dann per HTTP-01-Challenge automatisch Let's-Encrypt-Zertifikate für jeden Hostname.

4. Stack mgmt/ — Portainer + Matomo ​

Portainer läuft off-domain — direkt über die Server-IP, kein Caddy-Eintrag. Vorteil: kein Caddy/Portainer-Port-Konflikt, kein DNS-Eintrag, Admin-Plane bleibt sauber separiert. Self-Signed-TLS auf 9443, plain HTTP auf 9000.

bash
cd ~/stacks/mgmt
docker compose up -d
# Portainer-Setup-Wizard: https://<server-ip>:9443 (Self-Signed-Warnung akzeptieren)
# Matomo-Setup-Wizard:    https://analytics.matev.eu

5. Stack git/ — Forgejo + Woodpecker ​

Bleibt strukturell wie heute (siehe home/bengel/stacks/git/docker-compose.yml). OAuth2-Client legt man in Forgejo unter Site Administration → Applications an, mit Redirect-URL https://ci.matev.eu/authorize.

bash
cd ~/stacks/git
docker compose up -d

6. Stack apps/ — die Anwendungen ​

Die docker-compose.yml referenziert die Build-Contexts unter /var/www/<service>/. Die Dockerfiles liegen direkt im Webroot. Im home/bengel/stacks/apps/dockerfiles-reference/ liegen Vorlagen, die du nach /var/www/<service>/Dockerfile übertragen kannst.

Container-Convention ​

  • WORKDIR /var/www/html (Standard für FrankenPHP/Laravel/Kirby)
  • Container läuft als www-data mit angeglichener UID 33 / GID 33 (Host-Standard) → Bind-Mounts in /var/www/<service>/storage etc. funktionieren ohne Permission-Drama
  • Code wird per COPY ins Image gebacken (deterministisch, schneller Start)
  • Bind-Mounts decken nur veränderliche Pfade ab: storage/, content/, media/, .env
dockerfile
# Auszug aus jedem PHP-Dockerfile — UID-Angleichung
RUN deluser www-data \
 && addgroup -g 33 -S www-data \
 && adduser -u 33 -S -G www-data www-data
USER www-data

Hochfahren (Demo-Reihenfolge) ​

bash
cd ~/stacks/apps

# 1) DB + Cache zuerst
docker compose up -d apps-db apps-redis

# 2) Filament-PIM
docker compose build admin
docker compose up -d admin
docker compose exec admin php artisan migrate --force
docker compose exec admin php artisan db:seed --force
docker compose exec admin php artisan filament:optimize
docker compose exec admin php artisan octane:reload

# 3) Kirby-CMS
docker compose build cms
docker compose up -d cms
# Erstes Setup im Panel: https://cms.matev.eu/panel

# 4) Storybook (statisch — Build muss in /var/www/storybook/ liegen)
docker compose up -d storybook

# 5) Penpot (Sub-Compose)
docker compose -f penpot-compose.yml up -d

# 6) später: b2b, staging, docs
docker compose build b2b staging docs
docker compose up -d b2b staging docs

# 7) Taiga (eigener Stack, separates Verzeichnis)
cd ~/stacks/taiga
docker compose up -d

Was im admin-Container fertig ist ​

Der Filament-Container baut auf einer existierenden Code-Basis in /var/www/admin/ mit folgendem Funktionsumfang:

  • Filament 5.6 + Laravel 13.6 + PHP 8.4
  • Spatie: laravel-tags, laravel-settings, laravel-permission
  • bezhansalleh/filament-shield (Permission-UI im Panel)
  • laravel/sanctum (REST-API + Cross-Domain-Auth)
  • 22 Pimcore-Importer (php artisan import:pimcore --phase=1..5)
  • Search-Stack: laravel/scout + Meilisearch + OpenSearch + Ollama-Provider
  • Console-Commands: import:pimcore, blueprints:generate, opensearch:setup, ollama:setup, search:reindex, search:index-all
  • Spatie-Media-Library Pro-Vorbereitung (Migration + Trait)

Details in docs/LOCAL_DEV.md.

Smoke-Tests ​

bash
curl -I https://admin.matev.eu      # → 302 zu /admin/login
curl -I https://cms.matev.eu        # → 200
curl -I https://storybook.matev.eu  # → 200

7. Update-Workflow ​

bash
# 1) Stack-Definitionen aus Git ziehen
cd ~/stacks && git pull

# 2) Externe Images aktualisieren (matomo, mariadb, postgres, …)
cd ~/stacks/mgmt && docker compose pull && docker compose up -d
cd ~/stacks/git  && docker compose pull && docker compose up -d
cd ~/stacks/apps && docker compose pull && docker compose up -d

# 3) Selbstgebaute Images neu bauen — wenn sich /var/www/<service>/-Code geändert hat
cd ~/stacks/apps && docker compose build admin && docker compose up -d admin
docker compose exec admin php artisan octane:reload   # alter Worker-Code rauswerfen

In Portainer wird jeder Restart sichtbar; die UI nicht zum Editieren der Stacks benutzen — Source of Truth bleibt das Git-Repo.

Verwandte Themen ​