Symfony deployen ohne DevOps-Team: Docker, CI/CD und PHP-FPM-Tuning für Agenturen
Ihre Agentur hat eine großartige Symfony-Anwendung gebaut. Die Features stehen, die Tests sind grün, der Kunde ist zufrieden. Und dann kommt die Frage: Wie kommt das Ding auf einen Server? Plötzlich geht es nicht mehr um Twig-Templates und Doctrine-Entities, sondern um Nginx-Konfigurationen, PHP-FPM-Pools und SSL-Zertifikate.
Für viele Agenturen ist genau dieser Übergang vom Entwicklungsprojekt zum produktiven Betrieb der schmerzhafte Bruch. Die Entwickler können hervorragend Code schreiben, aber Server-Administration ist nicht ihr Kerngeschäft. In diesem Artikel zeigen wir, wie Sie Symfony-Anwendungen mit Docker produktionstauglich deployen — und warum ein Managed Symfony Hosting für die meisten Agenturen der wirtschaftlichere Weg ist.
Der aktuelle Symfony-Stack: Was Sie 2025/2026 wissen müssen
Symfony 7.4 LTS ist die aktuelle Langzeit-Support-Version (Release November 2025). Sie erfordert mindestens PHP 8.2, empfohlen wird PHP 8.3 oder 8.4. Bugfixes gibt es bis November 2028, Sicherheits-Updates bis November 2029. Für neue Projekte ist Symfony 7.4 LTS die richtige Wahl — maximale Stabilität bei aktuellem Feature-Set.
Parallel dazu existiert Symfony 8.0 für Early Adopter, das PHP 8.4 voraussetzt. Für produktive Agentur-Projekte empfehlen wir, bei der LTS-Version zu bleiben.
Der Software-Stack im Überblick
- PHP 8.3 oder 8.4 mit Extensions: pdo, pdo_mysql, intl, opcache, mbstring, gd, zip, exif, sodium
- Composer 2 für Dependency-Management
- Webserver: Nginx + PHP-FPM (klassisch) oder FrankenPHP (modern)
- Datenbank: MySQL 8.0/8.4, MariaDB 11 oder PostgreSQL 16
- Cache: Redis oder APCu für Session- und Application-Cache
- Asset Mapper (kein Node.js mehr nötig) oder Webpack Encore für Legacy-Projekte
Docker-Setup: FrankenPHP vs. PHP-FPM + Nginx
Beim Docker-Setup für Symfony stehen zwei Architekturansätze zur Wahl. Beide haben ihre Berechtigung — die Wahl hängt von Ihrem Projekt und Ihrer Erfahrung ab.
FrankenPHP: Der neue Standard
Das offizielle Symfony-Docker-Projekt (dunglas/symfony-docker) setzt seit 2024 auf FrankenPHP — einen in Go geschriebenen PHP-Application-Server, der auf dem Caddy-Webserver basiert. FrankenPHP vereint Webserver und PHP-Runtime in einem einzigen Prozess:
- Ein Container statt zwei: Kein separater Nginx-Container, kein FastCGI-Proxy. Weniger Komplexität, weniger Fehlerquellen.
- Automatisches HTTPS: Let’s-Encrypt-Zertifikate werden ohne zusätzliche Konfiguration erstellt und erneuert.
- HTTP/2 und HTTP/3: Nativ unterstützt, ohne Nginx-Konfiguration.
- Worker Mode: Die Symfony-Anwendung bleibt zwischen Requests im Speicher. Kein erneutes Bootstrapping bei jedem Request — das bedeutet deutlich schnellere Antwortzeiten.
Ein neues Symfony-Projekt mit FrankenPHP starten Sie mit:
symfony new my-project --docker
# oder direkt:
composer create-project symfony/skeleton my-project
cd my-project && composer require webapp
PHP-FPM + Nginx: Der klassische Weg
Für bestehende Projekte und Teams mit Nginx-Erfahrung bleibt der klassische Stack eine solide Wahl. PHP-FPM lauscht auf Port 9000, Nginx leitet Requests per FastCGI weiter:
# docker-compose.yml
services:
php:
build:
context: .
dockerfile: Dockerfile
volumes:
- ./:/app
environment:
APP_ENV: prod
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./:/app:ro
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- php
database:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: secret
MARIADB_DATABASE: app
volumes:
- db-data:/var/lib/mysql
redis:
image: redis:7-alpine
volumes:
db-data:
Das zugehörige Dockerfile für den PHP-Container:
FROM php:8.3-fpm-alpine
RUN apk add --no-cache icu-dev libzip-dev
&& docker-php-ext-install pdo_mysql intl opcache zip
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY . .
RUN APP_ENV=prod composer install --no-dev --no-scripts --prefer-dist --no-interaction
&& composer dump-autoload --optimize --classmap-authoritative --no-dev
&& php bin/console cache:warmup --env=prod
&& chown -R www-data:www-data var/cache var/log
USER www-data
Unsere Empfehlung: FrankenPHP für neue Projekte, PHP-FPM + Nginx für bestehende Setups und Teams die den klassischen Stack beherrschen.
PHP-FPM-Tuning: Die Parameter, die wirklich zählen
Ein falsch konfigurierter PHP-FPM-Pool ist der häufigste Grund für langsame Symfony-Anwendungen in Produktion. Die drei Process-Manager-Modi haben unterschiedliche Einsatzgebiete:
- static: Feste Anzahl Worker-Prozesse. Kein Fork-Overhead, sofort bereit. Ideal wenn der Server ausschließlich für die Symfony-App läuft.
- dynamic: Skaliert zwischen Minimum und Maximum. Guter Kompromiss für die meisten Produktions-Setups.
- ondemand: Forkt erst bei eingehendem Request. Minimaler Idle-Verbrauch, aber langsamer erster Request.
Empfohlene Produktionskonfiguration
# php-fpm.d/www.conf
pm = dynamic
pm.max_children = 60
pm.start_servers = 8
pm.min_spare_servers = 8
pm.max_spare_servers = 20
pm.max_requests = 500
Die entscheidende Berechnung: pm.max_children = verfügbarer RAM / RAM pro PHP-Prozess. Ein Symfony-Prozess verbraucht typischerweise 80 bis 120 MB. Bei einem Server mit 8 GB RAM, wovon 2 GB für Betriebssystem, Datenbank und Redis reserviert sind, bleiben 6 GB für PHP — also etwa 60 Worker.
OPcache: Der größte Performance-Hebel
OPcache speichert kompilierten PHP-Bytecode im Shared Memory. In Produktion ist eine korrekte OPcache-Konfiguration der wichtigste Performance-Faktor:
# php.ini
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0
opcache.preload = /app/config/preload.php
opcache.preload_user = www-data
Kritisch: validate_timestamps = 0 in Produktion. Damit prüft PHP nicht bei jedem Request, ob sich Dateien geändert haben. Das bringt messbare Performance-Gewinne — aber nach einem Deployment muss der PHP-FPM-Pool neu gestartet werden, damit neuer Code geladen wird.
Die Preloading-Funktion geht noch weiter: Symfony kann häufig verwendete Klassen beim FPM-Start in den Shared Memory laden. Das eliminiert File-I/O für den Autoloader bei jedem Request.
CI/CD-Pipeline: Vom Git Push zum Deployment
Eine automatisierte Pipeline nimmt Ihrem Team den manuellen Deployment-Prozess ab. Das typische Muster mit GitHub Actions:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: intl, pdo_mysql, opcache
- run: composer install --no-dev --prefer-dist
- run: php bin/phpstan analyse
- run: php bin/phpunit
- run: symfony security:check
build-and-deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker Image
run: docker build -t myapp:${{ github.sha }} .
- name: Push to Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker tag myapp:${{ github.sha }} ghcr.io/myorg/myapp:latest
docker push ghcr.io/myorg/myapp:latest
- name: Deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: deploy
key: ${{ secrets.SSH_KEY }}
script: |
cd /opt/myapp
docker compose pull
docker compose up -d
docker compose exec -T php php bin/console doctrine:migrations:migrate --no-interaction
Die Pipeline durchläuft vier Schritte: Code prüfen (PHPStan + PHPUnit), Docker-Image bauen, in eine Registry pushen, auf dem Server deployen. Der gesamte Prozess dauert typischerweise 3 bis 5 Minuten.
Doctrine-Migrations im Deployment
Datenbankmigrationen sind der heikelste Teil des Deployments. Wichtige Regeln:
- Migrationen VOR dem Code-Switch ausführen: Das Datenbankschema muss bereit sein, bevor die neue Version Traffic bekommt.
- Rückwärtskompatibilität: Jede Migration muss mit der alten UND neuen Codeversion funktionieren. Das bedeutet: Spalten erst im nächsten Release löschen, nicht im selben.
--no-interactionist Pflicht: Ohne dieses Flag wartet Doctrine auf manuelle Bestätigung — in einer Pipeline ein Deadlock.- Migrationen nur in Produktion ausführen, nie erstellen:
doctrine:migrations:diffgehört in die Entwicklung,doctrine:migrations:migratein die Pipeline.
Asset Mapper: Kein Node.js mehr nötig
Eine der erfreulichsten Neuerungen seit Symfony 6.3: Der Asset Mapper ersetzt Webpack Encore für die meisten Projekte. Statt eines Node.js-Build-Schritts mit npm, Webpack und Babel nutzt Asset Mapper native Browser-Features (ES Modules, Import Maps).
Was das für Ihr Deployment bedeutet:
- Kein Node.js auf dem Server: Eine Abhängigkeit weniger. Kein
npm install, keinyarn build, kein Webpack. - Schnelleres Deployment: Der Asset-Build entfällt komplett. Symfony verwaltet JavaScript-Imports über Import Maps.
- Weniger Docker-Image-Größe: Kein Node.js-Layer im Image spart 200 bis 400 MB.
Webpack Encore bleibt weiterhin verfügbar für Projekte, die React, Vue oder andere Frameworks mit Build-Step benötigen. Aber für klassische Symfony-Anwendungen mit Twig-Templates ist Asset Mapper die zeitgemäße Wahl.
Secrets und Umgebungsvariablen richtig handhaben
Symfony bietet mit dem Secrets Vault eine integrierte Lösung für sensitive Konfiguration. Pro Environment existiert ein eigener Vault mit verschlüsselten Key-Value-Paaren:
# Secret erstellen
php bin/console secrets:set DATABASE_URL --env=prod
# Secrets für Deployment in lokale Datei entschlüsseln
php bin/console secrets:decrypt-to-local --env=prod
Die Best Practices für Umgebungsvariablen:
.envwird committet — enthält nur Default- und Entwicklungswerte..env.localfür lokale Overrides — wird nicht committet..env.prodfür Production-Defaults — wird committet.- Sensitive Werte kommen über CI/CD-Secrets oder Docker Secrets auf den Server. Nie ins Repository, nie ins Docker-Image.
Ein häufiger Fehler: DATABASE_URL im Docker-Image hartcodieren. Was im Entwicklungs-Container praktisch erscheint, ist in Produktion ein Sicherheitsrisiko — jeder mit Zugriff auf das Image sieht die Zugangsdaten.
Die 10 häufigsten Deployment-Fehler
1. APP_ENV=dev in Produktion
Der Debug-Modus zeigt detaillierte Fehlermeldungen mit Stack Traces, Datenbankabfragen und Konfigurationswerten. Ein Sicherheits-Albtraum. Prüfen Sie nach jedem Deployment, dass APP_ENV=prod und APP_DEBUG=0 gesetzt sind.
2. Container als Root laufen lassen
Ohne explizite USER-Anweisung im Dockerfile laufen PHP-Prozesse als Root. Wenn ein Angreifer eine Schwachstelle ausnutzt, hat er Root-Zugriff im Container — und möglicherweise darüber hinaus.
3. OPcache validate_timestamps=1
Bei jedem Request prüft PHP, ob sich Dateien auf der Festplatte geändert haben. In Produktion ändert sich Code nur beim Deployment — die Prüfung ist reiner Overhead. Auf 0 setzen und nach dem Deployment PHP-FPM neustarten.
4. Keine Healthchecks
Ein Container kann “Running” sein, obwohl PHP-FPM intern tot ist. Docker Healthchecks erkennen das und starten den Container automatisch neu:
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
5. TRUSTED_PROXIES nicht gesetzt
Hinter einem Reverse Proxy (Traefik, Nginx, Cloudflare) bekommt Symfony die IP des Proxys statt des Clients. TRUSTED_PROXIES=REMOTE_ADDR in der .env löst das Problem.
6. Migrations nach dem Code-Switch
Die neue Codeversion erwartet Datenbank-Spalten, die noch nicht existieren. Ergebnis: 500er-Fehler für alle Benutzer. Migrationen immer vor dem Container-Switch ausführen.
7. Dev-Dependencies in Produktion
composer install ohne --no-dev installiert PHPUnit, PHPStan und andere Entwicklungstools. Das vergrößert das Image, verlangsamt den Autoloader und kann Sicherheitsrisiken einführen.
8. Kein –no-scripts bei Composer
Composer-Scripts können bei install automatisch ausgeführt werden. In einer CI/CD-Pipeline können diese Scripts fehlschlagen, weil die Datenbank noch nicht verfügbar ist. Besser: Scripts explizit im Dockerfile aufrufen.
9. Fehlende Security-Header
Content-Security-Policy, Strict-Transport-Security, X-Frame-Options — diese Header fehlen in der Nginx-Konfiguration, wenn man sie nicht explizit hinzufügt. Symfony’s Security-Bundle hilft bei einigen, aber die Webserver-Konfiguration muss stimmen.
10. SSL-Zertifikate manuell verwalten
Let’s Encrypt mit Certbot manuell einrichten und Renewal-Cronjobs pflegen ist fehleranfällig. Traefik oder Caddy (mit FrankenPHP) übernehmen das vollautomatisch — eine Sorge weniger.
Reverse Proxy mit Traefik
Für Produktions-Deployments mit PHP-FPM + Nginx empfehlen wir Traefik als Reverse Proxy. Labels in der docker-compose.yml reichen für die Konfiguration:
nginx:
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`app.example.com`)"
- "traefik.http.routers.myapp.entrypoints=websecure"
- "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
- "traefik.http.services.myapp.loadbalancer.server.port=80"
networks:
- traefik-proxy
- default
Traefik erkennt neue Container automatisch, fordert SSL-Zertifikate an und leitet Traffic an den richtigen Service weiter. Kein manuelles Nginx-Conf-Editieren, kein Certbot-Cronjob.
Messenger-Worker: Hintergrundaufgaben richtig deployen
Viele Symfony-Anwendungen nutzen den Symfony Messenger für asynchrone Aufgaben: E-Mails versenden, Bilder verarbeiten, Reports generieren, API-Synchronisationen. Im Entwicklungsmodus werden diese Jobs oft synchron abgearbeitet — in Produktion müssen dedizierte Worker-Prozesse laufen.
# docker-compose.yml — dedizierter Worker-Container
worker:
build:
context: .
dockerfile: Dockerfile
command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M -vv
restart: unless-stopped
depends_on:
- database
- redis
environment:
APP_ENV: prod
Die Parameter --time-limit und --memory-limit sind in Produktion unverzichtbar. Sie bewirken, dass der Worker sich nach einer Stunde oder bei 256 MB Speicherverbrauch selbst beendet und durch Docker (restart: unless-stopped) neu gestartet wird. Das verhindert Memory-Leaks und stellt sicher, dass nach einem Deployment neuer Code geladen wird.
Für Anwendungen mit hohem Durchsatz können Sie mehrere Worker-Instanzen parallel laufen lassen:
worker:
deploy:
replicas: 3
Oder in Docker Compose ohne Swarm-Mode über ein Scaling-Kommando:
docker compose up -d --scale worker=3
Ein oft übersehener Punkt: Worker müssen im Deployment-Prozess neu gestartet werden. Wenn Sie neuen Code deployen, läuft der alte Worker weiter mit dem alten Code im Speicher. Die Pipeline muss den Worker-Container explizit neu starten oder das Time-Limit so wählen, dass Worker regelmäßig recyclen.
Logging und Debugging in Produktion
Symfony’s Monolog-Bundle schreibt Logs standardmäßig nach var/log/. In einer Docker-Umgebung ist das suboptimal — Container sind ephemeral, Logs gehen beim Neustart verloren. Die bessere Strategie: Logs nach STDOUT/STDERR schreiben und Docker die Log-Aggregation überlassen.
# config/packages/monolog.yaml (prod)
monolog:
handlers:
main:
type: stream
path: "php://stderr"
level: error
console:
type: console
process_psr_3_messages: false
Damit landen Fehler in docker compose logs php und können von Log-Aggregation-Tools (Loki, ELK, Papertrail) abgeholt werden. Für eine detailliertere Übersicht empfehlen wir unseren Artikel über Monitoring für Self-Hosted-Stacks.
Debugging ohne var_dump
In Produktion fehlen Ihnen die Symfony-Toolbar und der Profiler. Einige Alternativen:
- Structured Logging: Kontext-Informationen in Log-Nachrichten mitgeben (
$logger->error('Payment failed', ['order_id' => $id, 'amount' => $amount])). - Sentry oder Bugsnag: Exception-Tracking-Services, die Stack Traces, Request-Daten und Breadcrumbs automatisch erfassen. Sentry bietet ein selbst hostbares Open-Source-Paket.
- Health-Endpoint: Ein einfacher Controller, der Datenbankverbindung, Redis-Verbindung und Dateisystem-Zugriff prüft und als JSON zurückgibt. Nützlich für Monitoring und schnelle Diagnose.
Security-Hardening für Symfony in Docker
Eine produktive Symfony-Anwendung muss gehärtet sein. Die wichtigsten Maßnahmen:
Container-Sicherheit
- Non-Root-User: Die
USER www-data-Anweisung im Dockerfile ist Pflicht. Kein Prozess im Container sollte als Root laufen. - Read-Only Filesystem: Volumes für
var/cacheundvar/logmounten, den Rest des Dateisystems als read-only markieren. - Keine Shell im Production-Image: Alpine-basierte Images ohne installierte Shell reduzieren die Angriffsfläche.
- Image-Scanning: Tools wie Trivy oder Snyk Container scannen Docker-Images auf bekannte Schwachstellen in Basis-Images und OS-Packages.
Symfony-spezifische Sicherheit
- Security-Checker:
symfony security:checkprüft Ihre Composer-Dependencies auf bekannte Schwachstellen. In die CI/CD-Pipeline einbauen. - CSRF-Protection: Symfony’s Form-Component schützt automatisch gegen Cross-Site-Request-Forgery — aber nur wenn Sie es nutzen. API-Endpoints brauchen alternative Schutzmechanismen (API-Tokens, OAuth).
- Rate-Limiting: Symfony’s RateLimiter-Component schützt Login-Endpoints und APIs vor Brute-Force-Angriffen. Konfigurieren Sie sinnvolle Limits pro IP und Benutzer.
- Content-Security-Policy: Ein gut konfigurierter CSP-Header verhindert XSS-Angriffe. Symfony’s NelmioSecurityBundle vereinfacht die Konfiguration.
Netzwerk-Sicherheit
In einer Docker-Umgebung sollten nur der Webserver und der Reverse Proxy aus dem Internet erreichbar sein. Datenbank, Redis und Worker kommunizieren ausschließlich über das interne Docker-Netzwerk. Eine Host-Firewall (UFW) als zusätzliche Schutzschicht ist empfehlenswert — beachten Sie aber, dass Docker standardmäßig eigene iptables-Regeln setzt und UFW umgehen kann. Die Lösung: Docker so konfigurieren, dass es iptables nicht manipuliert, oder spezifische Firewall-Regeln für Docker-Netzwerke setzen.
Was kostet Symfony-Deployment wirklich?
Die reine Server-Infrastruktur ist günstig: Ein VPS mit 4 Kernen, 8 GB RAM und SSD-Speicher kostet bei europäischen Anbietern 20 bis 40 Euro pro Monat. Aber die wahren Kosten liegen woanders:
- Initiale Einrichtung: Docker-Setup, CI/CD-Pipeline, SSL, Monitoring — rechnen Sie mit 2 bis 5 Arbeitstagen eines erfahrenen Entwicklers.
- Laufende Wartung: PHP-Updates, Security-Patches, Docker-Image-Updates, Zertifikatsmanagement. 4 bis 8 Stunden pro Monat.
- Firefighting: Der Server ist nachts nicht erreichbar, die Datenbank ist voll, PHP-FPM reagiert nicht. Diese ungeplanten Einsätze kommen in den ungünstigsten Momenten.
Bei einem internen Stundensatz von 90 Euro (Agentur-typisch) summiert sich das auf 400 bis 800 Euro monatlich — für eine einzelne Anwendung. Bei mehreren Kundenprojekten multipliziert sich der Aufwand.
Managed Symfony Hosting: Die Agentur-Lösung
Für Agenturen, die Symfony-Projekte abliefern aber keinen Ops-Aufwand betreiben wollen, ist Managed Hosting die wirtschaftlich sinnvollere Lösung:
- Docker-basierte Infrastruktur: PHP-FPM, Nginx, Redis und Datenbank als orchestrierter Stack — nicht als fragile Einzelinstallation.
- Automatische Deployments: Git Push, Pipeline läuft, Anwendung ist live. Ohne SSH-Zugang und manuelle Container-Restarts.
- PHP-FPM-Tuning inklusive: OPcache, Preloading, Worker-Konfiguration — optimiert für Symfony, nicht für WordPress.
- Monitoring und Backups: Tägliche Backups, Health-Checks, Alerting bei Problemen. Sie werden informiert, nicht geweckt.
- DSGVO-konform: Europäische Server, AVV, dokumentierte Datenverarbeitung.
Bei netzspitze.tech betreiben wir Symfony-Anwendungen als vollständig gemanagten Docker-Stack. Ihr Team konzentriert sich auf Features und Kundenprojekte — wir kümmern uns um PHP-FPM-Pools, OPcache-Invalidierung und nächtliche Datenbank-Backups.
Fazit: Deployment ist kein Agentur-Kerngeschäft
Symfony produktiv zu deployen ist kein Hexenwerk — aber es ist auch nicht trivial. Docker, CI/CD-Pipelines, PHP-FPM-Tuning und Security-Hardening erfordern Wissen, das in vielen Agenturen nicht zum Tagesgeschäft gehört. Und jede Stunde, die ein Entwickler mit Server-Konfiguration verbringt, fehlt bei der Feature-Entwicklung.
Wenn Sie gerade vor der Entscheidung stehen, wie Sie Ihr nächstes Symfony-Projekt deployen: In einem kostenlosen 15-Minuten-Call klären wir, ob Managed Hosting für Ihre Agentur passt — und wie der Umzug funktioniert.