# Symfony und Docker 2026: Entwicklungsumgebung und Production-Deployment > Ein vollständiger Symfony-Docker-Workflow für 2026: eine FrankenPHP-Entwicklungsumgebung, ein Multi-Stage-Production-Image, Worker-Modus, verschlüsselte Secrets, Image-Optimierung und Deployments ohne Ausfallzeit mit Migrationen. - Published: 2026-06-25 - Updated: 2026-07-06 - Author: SharpSkill - Tags: symfony, docker, frankenphp, php, deployment, devops - Reading time: 10 min --- Dieses Symfony-Docker-Tutorial baut einen vollständigen Container-Workflow auf: eine reproduzierbare lokale Entwicklungsumgebung und ein abgesichertes Production-Image, beide rund um FrankenPHP und Symfony 7.4 LTS. Der Betrieb von Symfony in Containern beseitigt die klassische Umgebungsdrift zwischen Laptop und Server und verwandelt jedes Deployment in die Auslieferung eines einzigen unveränderlichen Artefakts, statt in eine fragile Abfolge manueller Serverbefehle. > **Der Symfony-Docker-Stack 2026** > > Das offizielle Symfony-Docker-Template liefert inzwischen [FrankenPHP](https://frankenphp.dev/) als Runtime und ersetzt damit die traditionelle Kombination aus Nginx und PHP-FPM. Ein einziges Binary bedient HTTP/2 und HTTP/3 und betreibt Symfony im dauerhaften Worker-Modus, indem der Kernel zwischen den Requests gebootet bleibt, statt bei jedem Aufruf neu aufgebaut zu werden. ## Docker-Compose-Entwicklungsumgebung für Symfony Ein gutes Entwicklungssetup stellt jedem Mitwirkenden dieselbe PHP-Version, dieselben Erweiterungen und dieselbe Datenbank bereit, ohne den Host anzurühren. Docker Compose beschreibt diesen Stack deklarativ. Das folgende Beispiel kombiniert einen aus einem lokalen `Dockerfile` gebauten FrankenPHP-Container mit einem PostgreSQL-17-Dienst, beide im Standard-Compose-Netzwerk verdrahtet. ```yaml # compose.yaml services: php: build: context: . target: frankenphp_dev ports: - "80:80" - "443:443" volumes: - ./:/app # bind mount: edits on the host appear instantly - caddy_data:/data # persist TLS certificates across restarts environment: DATABASE_URL: "postgresql://app:app@database:5432/app?serverVersion=17" depends_on: - database database: image: postgres:17-alpine environment: POSTGRES_DB: app POSTGRES_USER: app POSTGRES_PASSWORD: app volumes: - db_data:/var/lib/postgresql/data volumes: caddy_data: db_data: ``` Der Bind Mount am `php`-Dienst bildet das Projektwurzelverzeichnis in den Container ab, sodass Dateiänderungen ohne Neubau des Images wirksam werden. FrankenPHP erzeugt beim ersten Start ein lokales TLS-Zertifikat, weshalb Port 443 freigegeben ist und `caddy_data` als benanntes Volume dient: Das Zertifikat übersteht ein `docker compose down`. Der Hinweis `serverVersion=17` in der DSN erlaubt Doctrine, bei jeder Verbindung eine Abfrage zur Versionserkennung zu überspringen. Sobald der Stack läuft, wird jeder Symfony-Befehl innerhalb des `php`-Containers ausgeführt statt auf dem Host, was die korrekte PHP-Version und die passenden Erweiterungen garantiert. Eine Datenbank anlegen, Migrationen ausführen oder eine Shell öffnen folgen alle demselben Muster über `docker compose exec`. > **Console-Befehle im Container ausführen** > > Symfony- und Composer-Aufrufe werden mit `docker compose exec php` vorangestellt, um sie gegen die PHP-Runtime des Containers laufen zu lassen, etwa `docker compose exec php bin/console make:entity` oder `docker compose exec php composer require symfony/uid`. Auf dem Host muss nie PHP installiert sein. ## Multi-Stage-Dockerfile für die Symfony-Production Ein [Multi-Stage-Dockerfile](https://docs.docker.com/build/building/multi-stage/) teilt sich eine gemeinsame Basis und verzweigt anschließend in ein Development- und ein Production-Ziel. Die Production-Stufe installiert nur Runtime-Abhängigkeiten, verwirft die Composer-Dev-Pakete und wärmt den Cache zur Build-Zeit vor, damit der laufende Container sofort startbereit ist. ```dockerfile # Dockerfile FROM dunglas/frankenphp:1-php8.4 AS base WORKDIR /app # Install the PHP extensions Symfony relies on RUN install-php-extensions \ intl \ opcache \ pdo_pgsql \ zip COPY --from=composer/composer:2-bin /composer /usr/bin/composer # --- Development target --- FROM base AS frankenphp_dev ENV APP_ENV=dev RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" # --- Production target --- FROM base AS frankenphp_prod ENV APP_ENV=prod ENV FRANKENPHP_CONFIG="worker ./public/index.php" ENV APP_RUNTIME="Runtime\FrankenPhpSymfony\Runtime" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" # Layer caching: copy manifests first, install, then copy the source COPY composer.json composer.lock symfony.lock ./ RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist COPY . . RUN composer dump-autoload --no-dev --classmap-authoritative \ && composer dump-env prod \ && bin/console cache:warmup ``` Das Kopieren von `composer.json` und den Lock-Dateien vor dem restlichen Quellcode ist die entscheidende Optimierung: Docker cacht die Schicht zur Abhängigkeitsinstallation und führt sie nur bei Änderungen an den Manifesten erneut aus, nicht bei jeder Codeänderung. Das Flag `--classmap-authoritative` teilt Composer mit, dass die Classmap vollständig ist, sodass der Autoloader zur Laufzeit nie auf Dateisystemsuchen zurückfällt. Das Vorwärmen des Caches während des Builds sorgt dafür, dass der erste Production-Request auf einen vollständig kompilierten Container trifft. ## FrankenPHP-Worker-Modus und die Symfony-Runtime Das klassische PHP-FPM bootet den Symfony-Kernel, verarbeitet einen Request und verwirft anschließend alles. Der Worker-Modus hält den Kernel und den Dependency-Injection-Container über Requests hinweg am Leben, und genau daher stammt der größte Teil der Latenzersparnis. Das Paket `runtime/frankenphp-symfony` ermöglicht dies über die Symfony-Runtime-Komponente und den Standard-Bootstrap `public/index.php`. ```php // public/index.php use App\Kernel; require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; return function (array $context) { return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); }; ``` Die im Dockerfile gesetzte Zeile `FRANKENPHP_CONFIG="worker ./public/index.php"` aktiviert diese Schleife. Weil der Container wiederverwendet wird, muss jeder Dienst, der requestspezifischen Zustand hält, sich zwischen den Requests selbst zurücksetzen. Framework-Dienste behandelt Symfony automatisch, doch eigene zustandsbehaftete Dienste sollten `Symfony\Contracts\Service\ResetInterface` implementieren und mit `kernel.reset` getaggt werden, damit sich angesammelter Zustand nicht in den nächsten Request überträgt. Dies ist dieselbe Disziplin, die langlaufende [Messenger-Worker](/blog/symfony/symfony-messenger-queues-workers-async-architecture) erfordern, und der Lohn ist ein Request-Durchsatz, der um ein Vielfaches höher liegt als bei PHP-FPM auf derselben Hardware. Für die lokale Entwicklung startet FrankenPHP mit dem Flag `--watch` den Worker bei Dateiänderungen automatisch neu, sodass der Worker-Modus einer Edit-Refresh-Schleife nicht im Weg steht. ## FrankenPHP gegenüber PHP-FPM in Symfony Die Wahl zwischen FrankenPHP und dem klassischen Stack aus Nginx und PHP-FPM prägt sowohl das Dockerfile als auch das Laufzeitverhalten. Die folgende Tabelle fasst die praktischen Unterschiede für ein Symfony-Production-Setup zusammen. | Aspekt | FrankenPHP-Worker-Modus | Nginx + PHP-FPM | |--------|------------------------|-----------------| | Container | Einer | Zwei (Webserver + PHP) | | Kernel-Boot | Einmal pro Worker | Einmal pro Request | | HTTP/3 | Integriert | Erfordert Zusatzkonfiguration | | TLS | Automatisch (Caddy) | Manuelle Zertifikatseinrichtung | | Zustandsverwaltung | Muss zwischen Requests zurückgesetzt werden | Pro Request isoliert | | Cold-Start-Kosten | Einmalig beim Boot bezahlt | Bei jedem Request bezahlt | Der Worker-Modus gewinnt beim Durchsatz, weil er den teuren Schritt aus Kernel-Boot und Container-Kompilierung über Tausende von Requests verteilt. PHP-FPM bleibt relevant, wenn eine Anwendung auf Globals oder Drittanbieter-Bibliotheken setzt, die einen frischen Prozess pro Request voraussetzen, denn diese brechen unter einem wiederverwendeten Worker. > **Zustandslecks im Worker-Modus** > > Ein Dienst, der Request-Daten in einer privaten Eigenschaft zwischenspeichert, liefert die Daten des einen Requests an den nächsten aus, sofern er sich nicht zurücksetzt. Vor dem Umstieg auf den Worker-Modus in Production sollten Singletons, Event-Subscriber und alles, was ein Security-Token oder den aktuellen Request hält, geprüft werden. ## Umgebungsvariablen und Secrets in der Production verwalten Symfony liest seine Konfiguration aus Umgebungsvariablen, und es gibt zwei solide Wege, sie in der Production bereitzustellen. Der erste ist das Einspeisen einfacher Variablen über den Orchestrator oder die Compose-Datei. Der zweite ist Symfonys verschlüsselter Secrets-Vault, der sensible Werte als Chiffretext im Repository ablegt und sie zur Laufzeit mit einem einzigen privaten Schlüssel entschlüsselt. ```bash # Generate the keypair; commit the public key, keep the private key out of the image php bin/console secrets:generate-keys # Encrypt a value into config/secrets/prod/ php bin/console secrets:set DATABASE_URL # In production, expose only the decryption key to the container export SYMFONY_DECRYPTION_SECRET="$(cat config/secrets/prod/prod.decrypt.private.php)" ``` Der Schritt `composer dump-env prod` im Dockerfile kompiliert die `.env`-Dateien zu einer einzigen optimierten `.env.local.php`, was die Laufzeitkosten für das Parsen der Dotenv-Dateien beim Boot beseitigt. Jede in der realen Container-Umgebung gesetzte Variable überschreibt weiterhin die kompilierten Standardwerte, sodass vom Orchestrator bereitgestellte Secrets immer gewinnen. Der [Symfony-Deployment-Guide](https://symfony.com/doc/current/deployment.html) dokumentiert die vollständige Vorrangreihenfolge, doch die praktische Regel ist einfach: `APP_SECRET` oder Datenbank-Zugangsdaten niemals ins Image einbacken, sondern beim Container-Start einspeisen. ## Das Symfony-Production-Image optimieren Zwei OPcache-Einstellungen sind für den Großteil des Performance-Unterschieds in der Production verantwortlich: das Deaktivieren der Timestamp-Prüfung und das Aktivieren des Preloadings. Weil ein Container-Image unveränderlich ist, ändert sich der Quellcode zur Laufzeit nie, weshalb OPcache niemals Dateien statten sollte, um auf Änderungen zu prüfen. Das Preloading geht weiter, indem es Symfony- und Anwendungsklassen einmal beim Serverstart in den gemeinsamen Speicher lädt. ```ini ; docker/php/prod.ini opcache.enable=1 opcache.preload=/app/var/cache/prod/App_KernelProdContainer.preload.php opcache.preload_user=root opcache.memory_consumption=256 opcache.max_accelerated_files=20000 opcache.validate_timestamps=0 ; immutable image: never re-check the filesystem realpath_cache_size=4096K realpath_cache_ttl=600 ``` Symfony erzeugt die oben aufgeführte Datei `preload.php` während `cache:warmup`, sodass sie nach dem Build bereits im Image vorhanden ist. [OPcache-Preloading](https://www.php.net/manual/en/opcache.preloading.php) verlinkt diese Klassen beim Start und überspringt den Kompilierungsschritt beim ersten Request, der sie benötigt. Das Setzen von `validate_timestamps=0` ist nur bei unveränderlichen Deployments sicher, bei denen ein neues Release ein neues Image bedeutet; auf einem veränderlichen Host würde es veralteten Code ausliefern. `max_accelerated_files` über die tatsächliche Dateizahl hinaus zu bemessen, hält das gesamte Framework ohne Verdrängung im Cache. ## Die Symfony-Image-Größe mit .dockerignore reduzieren Eine fehlende `.dockerignore`-Datei bläht den Build unbemerkt auf. Ohne sie liefert der Schritt `COPY . .` das lokale `vendor`-Verzeichnis, den `var/cache` aus der Entwicklung, das Node-Build-Ergebnis und die `.git`-Historie direkt ins Image und in den Build-Kontext. Sie auszuschließen verkleinert das Image, beschleunigt das Hochladen des Build-Kontexts zum Daemon und verhindert, dass Entwicklungsartefakte die frisch installierten Production-Abhängigkeiten überschreiben. ```gitignore # .dockerignore /.git/ /vendor/ /node_modules/ /var/ /.env.local /.env.*.local /tests/ /docker/ compose*.yaml Dockerfile ``` Das Ignorieren von `/vendor/` zählt am meisten: Die Production-Stufe führt `composer install --no-dev` aus, sodass das Mitliefern des entwicklungsgeprägten Vendor-Verzeichnisses vom Host diesen Schritt vollständig zunichtemachen würde. Das Ausschließen von `/var/` hält den Entwicklungs-Cache und die Logs aus dem Image heraus und lässt das `cache:warmup` zur Build-Zeit einen sauberen Production-Cache erzeugen. Kombiniert mit dem Multi-Stage-Build und Alpine-basierten Erweiterungen landet ein schlankes Symfony-Image typischerweise deutlich unter 150 MB. ## Symfony-Container deployen und Migrationen ausführen Die Production-Compose-Datei referenziert ein vorgebautes Image aus einer Registry, statt lokal zu bauen, und definiert einen Health-Check, sodass der Orchestrator Traffic nur an einen Container leitet, der antwortet. Secrets kommen als Umgebungsvariablen an, niemals als Image-Layer. ```yaml # compose.prod.yaml services: php: image: registry.example.com/app:${TAG} environment: APP_ENV: prod APP_SECRET: ${APP_SECRET} DATABASE_URL: ${DATABASE_URL} healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost/health"] interval: 10s timeout: 3s retries: 3 restart: unless-stopped ``` Der Health-Check ruft eine `/health`-Route per curl auf, also muss die Anwendung eine solche bereitstellen. Ein minimaler Controller, der eine 200 zurückgibt, ohne die Datenbank zu berühren, hält die Prüfung schnell und vermeidet, dass der Container bei einem vorübergehenden Datenbankaussetzer als ungesund markiert wird. Wenn die Datenbankbereitschaft zählt, kann eine zweite, tiefere Prüfung eine leichtgewichtige Abfrage ausführen, doch der Liveness-Endpunkt bleibt trivial. ```php // src/Controller/HealthController.php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; final class HealthController extends AbstractController { #[Route('/health', name: 'health', methods: ['GET'])] public function __invoke(): JsonResponse { return new JsonResponse(['status' => 'ok']); } } ``` Datenbankmigrationen sollten als eigenständiger Schritt laufen, bevor die neuen Container Traffic übernehmen, nicht innerhalb des Anwendungs-Boots. Sie in einem einmaligen Container auszuführen garantiert, dass das Schema pro Release genau einmal aktuell ist, selbst wenn mehrere Anwendungsreplikate parallel starten. ```bash # deploy.sh set -euo pipefail docker compose -f compose.prod.yaml pull # Apply migrations once, in an isolated container docker compose -f compose.prod.yaml run --rm php \ bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration # Start replicas and wait for the health check to pass docker compose -f compose.prod.yaml up -d --wait ``` Das Flag `--wait` blockiert, bis jeder Dienst als gesund meldet, und gibt dem Deploy-Skript ein echtes Erfolgssignal statt eines Fire-and-Forget-Starts. In Verbindung mit einem Rolling Update in einem Orchestrator wie Kubernetes oder Docker Swarm ergibt dies Releases ohne Ausfallzeit: Die alten Container bedienen weiter, bis die neuen ihren Health-Check bestehen. Für einen breiteren Überblick über die Framework-Versionen, auf die dieser Workflow zielt, decken die [Symfony-Plattformübersicht](/technologies/symfony) und der [Feature-Guide zu Symfony 8 und PHP 8.4](/blog/symfony/symfony-8-new-features-php-84-lazy-objects) ab, was sich in der aktuellen Release-Linie geändert hat. ## Fazit - Ein Multi-Stage-Dockerfile verwenden, damit sich Development- und Production-Image eine Basis teilen, während das Production-Ziel ohne Composer-Dev-Abhängigkeiten ausgeliefert wird - `composer.json` und die Lock-Dateien vor dem Quellcode kopieren, um die Schicht zur Abhängigkeitsinstallation über Codeänderungen hinweg im Cache zu halten - FrankenPHP im Worker-Modus betreiben, um den gebooteten Kernel über Requests hinweg wiederzuverwenden, und zustandsbehaftete Dienste mit `ResetInterface` zurücksetzen, um Zustandslecks zu verhindern - Den Symfony-Cache vorwärmen und `.env` mit `dump-env prod` zur Build-Zeit kompilieren, damit der laufende Container vollständig initialisiert startet - OPcache-Preloading aktivieren und `validate_timestamps=0` in der Production setzen, was gerade deshalb sicher ist, weil das Image unveränderlich ist - `APP_SECRET`, Datenbank-Zugangsdaten und den Entschlüsselungsschlüssel des Vaults aus den Image-Layern heraushalten und beim Container-Start als Umgebungsvariablen einspeisen - Doctrine-Migrationen in einem einmaligen Container ausführen, bevor die neuen Replikate Traffic übernehmen, und das Rollout mit dem Health-Check über `up -d --wait` absichern --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/de/blog/symfony/symfony-docker-development-production