Docker Compose für lokale Symfony- und Next.js-Entwicklung: Das Setup, das deinen Workflow nicht ausbremst
Die meisten Teams, die einen Symfony- und Next.js-Stack für die Produktion containerisieren, versuchen irgendwann, dasselbe Setup für die lokale Entwicklung wiederzuverwenden – und genau da wird es meistens langsam. Ein Docker-Compose-Setup für Symfony und Next.js, das für die Produktion gebaut wurde, ist auf Image-Größe und Unveränderlichkeit optimiert. Die lokale Entwicklung braucht etwas anderes: schnelle Dateisynchronisation, Hot Reload und die Möglichkeit, in einem laufenden Container nachzuschauen, ohne gleich alles neu zu starten. Dieser Beitrag behandelt genau dieses Setup, nicht das Produktions-Setup. Wer nach Produktionsmustern für Docker sucht, findet die separat in Containerisierung einer Symfony-Anwendung.
Die Services, die du wirklich brauchst
Eine lokale Symfony- und Next.js-Umgebung braucht in der Regel sechs Container: PHP-FPM, Nginx, PostgreSQL, Redis, Mailpit zum Abfangen ausgehender E-Mails und den Next.js-Dev-Server. Hier ein funktionierendes docker-compose.yml dafür:
services:
php:
build:
context: .
dockerfile: docker/php/Dockerfile
target: dev
volumes:
- ./api:/var/www/app
- php_vendor:/var/www/app/vendor
environment:
APP_ENV: dev
DATABASE_URL: postgresql://app:app@postgres:5432/app
depends_on:
- postgres
- redis
nginx:
image: nginx:1.27-alpine
volumes:
- ./api/public:/var/www/app/public
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
ports:
- '8080:80'
depends_on:
- php
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: app
volumes:
- pg_data:/var/lib/postgresql/data
ports:
- '5432:5432'
redis:
image: redis:7-alpine
ports:
- '6379:6379'
mailpit:
image: axllent/mailpit:latest
ports:
- '8025:8025'
- '1025:1025'
web:
build:
context: ./web
dockerfile: Dockerfile.dev
volumes:
- ./web:/app
- web_node_modules:/app/node_modules
environment:
NEXT_PUBLIC_API_URL: http://php:8080
ports:
- '3000:3000'
depends_on:
- nginx
volumes:
pg_data:
php_vendor:
web_node_modules:
Bevor es um das Volume-Problem geht, sind zwei Dinge erwähnenswert. Erstens bekommen vendor und node_modules jeweils eigene benannte Volumes, statt vom Host aus gemountet zu werden. Das ist keine Optimierung, sondern notwendig: Wer diese Verzeichnisse als Bind-Mount einbindet, bekommt Konflikte zwischen npm bzw. Composer und den Dateiberechtigungen des Host-Dateisystems – und unter macOS ist genau die schiere Menge kleiner Dateien in node_modules die Ursache für das im nächsten Abschnitt beschriebene Performance-Problem. Zweitens spricht die Next.js-App über den Service-Namen nginx mit der API, nicht über localhost. Beide Container liegen standardmäßig im selben Compose-Netzwerk, daher löst sich http://php:8080 innerhalb des Containers auf, obwohl die Adresse auf dem Host-Rechner nichts bedeutet.
Das macOS-Volume-Problem
Wer unter macOS entwickelt und den Symfony-Anwendungscode mit einem einfachen volumes: - ./api:/var/www/app bindet, wird merken, dass Composer-Installationen langsam sind, PHPUnit-Läufe länger dauern als nötig und der Cache-Warmup von Symfony zäh ist. Das ist weniger ein Docker-Desktop-Bug als ein strukturelles Problem: Docker unter macOS läuft in einer Linux-VM, und jeder Lese- oder Schreibzugriff über den Bind-Mount überquert diese VM-Grenze. Bei PHPs Gewohnheit, pro Anfrage Tausende kleine Dateien zu öffnen (autogeladene Klassen, gecachte Container-Definitionen, Übersetzungskataloge), summiert sich dieser Overhead schnell.
Es gibt zwei praktische Lösungen. Die erste ist der Wechsel zu VirtioFS als Datei-Sharing-Implementierung in Docker Desktop, das schneller ist als das ältere gRPC-FUSE-Backend und keine Änderungen an der Compose-Datei erfordert. Das ist eine Einstellung in den Docker-Desktop-Präferenzen, und für die meisten Teams reicht das, um die Verlangsamung erträglich zu machen.
Die zweite, gründlichere Lösung heißt Mutagen und synchronisiert Dateien zwischen Host und Container, statt sie direkt zu mounten. Mutagen beobachtet das Host-Dateisystem und überträgt Änderungen asynchron in den Container, sodass PHP lokale Dateien von der eigenen Festplatte des Containers liest statt über die VM-Grenze. Docker Compose unterstützt Mutagen nativ über docker compose watch (dazu gleich mehr), oder man führt Mutagen direkt mit einer in mutagen.yml definierten Sync-Session aus:
sync:
defaults:
ignore:
vcs: true
paths:
- vendor
- var/cache
php-code:
alpha: './api'
beta: 'docker://project-php-1/var/www/app'
mode: 'two-way-resolved'
Teams auf Apple Silicon mit aktueller Docker-Desktop-Version und aktiviertem VirtioFS brauchen Mutagen oft gar nicht mehr. Teams mit älterer Hardware oder großen Codebasen mit vielen Vendor-Dateien profitieren meistens davon.
Watch-Modus: Hot Reload ohne Neubau
Der andere wiederkehrende Reibungspunkt ist, den PHP-Container bei jeder Codeänderung neu zu bauen. Wenn das Dockerfile Anwendungscode zur Build-Zeit ins Image kopiert (was für die Produktions-Stage richtig ist), erledigt ein Bind-Mount in der Entwicklung das meiste davon automatisch. Es gibt aber weiterhin Fälle, in denen Compose Dateien aktiv synchronisieren und einen Service neu starten soll, ohne komplett neu zu bauen: eine Aktualisierung der composer.json, eine Änderung am Dockerfile selbst oder das Synchronisieren statischer Assets in den Next.js-Container.
Der watch-Modus von Docker Compose, Teil von Compose v2.22 und neuer, löst das deklarativ:
services:
php:
develop:
watch:
- action: sync
path: ./api/src
target: /var/www/app/src
- action: rebuild
path: ./api/composer.json
web:
develop:
watch:
- action: sync
path: ./web/src
target: /app/src
- action: rebuild
path: ./web/package.json
Mit docker compose watch synchronisiert Compose Quellcode-Änderungen sofort in den laufenden Container, während Änderungen an composer.json oder package.json einen gezielten Neubau nur dieses Service auslösen. Damit bekommst du für Quellcode-Dateien einen Großteil dessen, was Mutagen bietet, ohne ein separates Tool konfigurieren zu müssen – ersetzt aber nicht Mutagens kontinuierliche Zwei-Wege-Synchronisation für große Verzeichnisse wie vendor.
Datenbank-Seeding und Fixtures
Eine Entwicklungsumgebung, die jedes Mal leer startet, bremst das ganze Team, weil jeder Entwickler dieselben Testkonten und Beispieldaten manuell neu anlegt. Das Doctrine Fixtures Bundle löst das für Symfony gut:
docker compose exec php bin/console doctrine:fixtures:load --no-interaction
Diesen Befehl zusammen mit Migrationen in ein Makefile-Target zu packen sorgt dafür, dass ein frischer Klon des Repositories mit einem einzigen Befehl statt mehrerer manueller Schritte eine funktionierende Datenbank bekommt. Fixture-Klassen gehören nach src/DataFixtures, und es lohnt sich, eine TestFixtures-Gruppe getrennt von einer DevFixtures-Gruppe zu halten, damit die CI einen minimalen Datensatz laden kann, während die lokale Entwicklung etwas näher an einem vollständigen Seed bekommt.
Ein Makefile, das die Docker-Compose-Beschwörungsformeln versteckt
Niemand möchte docker compose exec php bin/console cache:clear fünfmal am Tag tippen. Ein schlanker Makefile-Wrapper hält die gängigen Befehle kurz und im Team konsistent:
.PHONY: dev test migrate shell logs
dev:
docker compose watch
test:
docker compose exec php bin/phpunit
migrate:
docker compose exec php bin/console doctrine:migrations:migrate --no-interaction
shell:
docker compose exec php bash
logs:
docker compose logs -f php web
make dev, make test, make migrate, make shell. Neue Entwickler müssen die zugrunde liegenden Compose-Befehle nicht kennen, um am ersten Tag produktiv zu sein, und das Makefile dient gleichzeitig als Dokumentation für alle, die später nachvollziehen wollen, wie die Umgebung funktioniert.
Wo das zur Produktion passt
Die hier beschriebene Umgebung soll die Produktion eng genug widerspiegeln, dass "läuft bei mir"-Probleme selten sind, ohne Produktionsthemen wie Multi-Stage-Build-Optimierung oder Image-Größe in den täglichen Workflow zu tragen. Wenn dein Team Symfony oder Next.js lokal noch ganz ohne Container betreibt, oder wenn das aktuelle Setup so weit von der Produktion abgedriftet ist, dass Deployment-Überraschungen an der Tagesordnung sind, ist das meist ein Zeichen dafür, dass der zugrunde liegende Custom-Software-Development-Prozess einen genaueren Blick braucht, nicht nur die Docker-Compose-Datei. Dasselbe gilt, wenn das lokale Setup so komplex geworden ist, dass das Onboarding eines neuen Entwicklers Tage statt einer Stunde dauert: Diese Komplexität ist meist ein Symptom einer Codebasis, die ihrer ursprünglichen Architektur entwachsen ist – genau das Problem, das ein Code-Quality-Consulting-Engagement diagnostizieren soll.
Wenn du eine zweite Meinung zu deinem lokalen Entwicklungssetup möchtest oder Hilfe, um einen Symfony- und Next.js-Stack produktionsreif zu machen, melde dich unter hello@wolf-tech.io oder schau dir an, was wir bei wolf-tech.io machen.

