Docker Compose für lokale Symfony- und Next.js-Entwicklung: Das Setup, das deinen Workflow nicht ausbremst

#docker compose symfony nextjs
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

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.