Hot Reload in einem Container ist eines dieser Dinge, die in jedem Tutorial sofort funktionieren und in deinem Projekt dann gar nicht. Der Grund ist immer derselbe: ein Dev-Server, der unter der Annahme geschrieben wurde, dass er und der Browser auf derselben Maschine sitzen — und jetzt tun sie das nicht.
Vier Einstellungen beheben das. Hier steht, was jede davon eigentlich löst.
Zuerst: yarn bekommt einen eigenen Container
Node gehört nicht in deinen PHP-Container. Es ist eine eigene Laufzeitumgebung mit eigenem Lebenszyklus und eigenen Gründen, neu gestartet zu werden — also bekommt es einen eigenen Service:
services:
yarn:
image: node:20
working_dir: /var/www/site
volumes:
- ./project:/var/www/site
command: yarn watch
ports:
- "8080:8080"
container_name: myproject_yarn_dev
Das heißt auch, dass du yarn nie auf dem Host ausführst — dieselbe Regel wie alles andere in meinem lokalen Environment-Setup. Verschiedene Projekte wollen verschiedene Node-Versionen, und das ist ein Tag in der Compose-Datei statt eines Versionsmanagers auf deinem Laptop.

Die Konfiguration
Mit Webpack Encore kommt das in die webpack.config.js:
.configureDevServerOptions(options => {
options.liveReload = true; // bei Änderung neu laden
options.hot = true; // Hot Module Replacement
options.host = '0.0.0.0'; // auf allen Interfaces lauschen
options.port = 8080;
options.proxy = {
'/': {
target: 'http://web/', // der Backend-Service
changeOrigin: false, // den originalen Host-Header behalten
secure: false, // Zertifikate nicht prüfen
pathRewrite: { '^/': '' },
},
};
})
Jetzt zu den vier Dingen, auf die es ankommt.
1. host: '0.0.0.0'
Dev-Server binden sich standardmäßig an localhost, was innerhalb eines Containers "nur dieser Container" bedeutet. Docker veröffentlicht den Port, du machst ihn im Browser auf und bekommst Connection Refused — während das Log des Containers fröhlich meldet, der Server laufe auf Port 8080.
Die Bindung an 0.0.0.0 lässt ihn auf allen Interfaces lauschen, und genau das lässt den veröffentlichten Port ihn erreichen. Das ist der mit Abstand häufigste Grund, warum Hot Reload "in Docker nicht geht", und es hat mit Hot Reload nichts zu tun.
2. target: 'http://web/'
Der Dev-Server leitet alles, was kein Asset ist, an dein Backend weiter. Auf einer normalen Maschine ist dieses Backend localhost. In einem Container ist es das ganz sicher nicht — localhost ist der yarn-Container, in dem nichts lauscht.
Nimm den Servicenamen aus Compose. Dockers internes DNS löst web auf den richtigen Container auf, und es funktioniert weiter, wenn sich die IP ändert.
3. changeOrigin: false — der wichtige
Das ist die Einstellung, die mich am meisten Zeit gekostet hat. Nicht weil der Standard falsch wäre — false ist der Standard —, sondern weil die Hälfte der Docker-Tutorials, aus denen du kopierst, ihn auf true setzt, und es der letzte Ort ist, an dem du suchen würdest.
Mit changeOrigin: true schreibt der Proxy den Host-Header auf das Ziel um — dein Backend bekommt also Requests, die behaupten, für web zu sein. Jede Anwendung, die sich für den Hostnamen interessiert, verhält sich dann anders: Multi-Site-Setups lösen die falsche Seite auf, erzeugte absolute URLs zeigen auf web, und Redirects schicken den Browser irgendwohin, das es außerhalb des Docker-Netzwerks nicht gibt.
Mit changeOrigin: false überlebt der originale Host-Header. Das Backend sieht den Request als von localhost:8080 kommend, also von dort, wo der Browser tatsächlich ist, und alles, was es erzeugt, zeigt irgendwohin, das der Browser erreichen kann.
Wenn deine App absolute URLs erzeugt, sich für die Domain interessiert oder mehr als eine Seite ausliefert, muss changeOrigin false sein. TYPO3 und Multi-Site-Symfony fallen beide direkt hier hinein.
4. Der Websocket
Hot Module Replacement ist kein HTTP — der Browser öffnet einen Websocket zurück zum Dev-Server und wartet auf "dieses Modul hat sich geändert"-Nachrichten. Wenn Assets neu gebaut werden und der Browser sich nie aktualisiert, ist das hier kaputt.
Dem Browser muss gesagt werden, wohin er sich verbinden soll, und aus einem Container heraus kann er es nicht selbst herausfinden:
options.client = {
webSocketURL: 'ws://localhost:8080/ws',
};
options.allowedHosts = 'all';
allowedHosts: 'all' ist nötig, weil der Dev-Server Requests mit einem Host-Header ablehnt, den er nicht kennt — und hinter einem Proxy sind das die meisten. Hier ist das in Ordnung: Dieser Server existiert nur auf deiner Maschine und sollte nirgendwo sonst erreichbar sein.
Wenn Dateiänderungen gar nicht erkannt werden
Ein anderes Problem mit demselben Symptom. Auf manchen Docker-Setups — besonders Docker Desktop unter macOS und Windows — kommen Dateisystem-Events nicht über den Mount, der Watcher erfährt von deiner Änderung also nie.
options.watchFiles = {
options: { usePolling: true, interval: 1000 },
};
Polling kostet CPU, schalte es also nur ein, wenn du es brauchst. Der Test ist simpel: Ändere eine Datei und schau, ob der Container einen Rebuild loggt. Nichts im Log heißt, der Watcher hat es nicht gesehen; ein Rebuild ohne Browser-Update heißt, der Websocket ist das Problem.
Das gehört nie auf einen Server
Der Dev-Server ist für die Entwicklung. Er beobachtet Dateien, hält Quellen im Speicher und liefert unminifizierte Ausgabe — nichts davon gehört auf einen Server.
Produktion baut Assets einmal, und die entstandenen Dateien liefert dein Webserver aus:
docker compose run --rm yarn yarn build
Gleicher Container, gleiche Node-Version wie in der Entwicklung, kein Dev-Server. Wenn du in CI baust, ist es derselbe Befehl, und die Artefakte werden ins Image kopiert.
Die Debugging-Reihenfolge
Wenn es nicht läuft, prüf in dieser Reihenfolge — jede schließt die Schicht darunter aus:
- Erreichst du den Dev-Server überhaupt? Wenn nicht, ist es
host. - Antwortet das Backend durch ihn hindurch? Wenn nicht, ist es das Proxy-
target. - Wirst du irgendwohin Seltsames umgeleitet? Das ist
changeOrigin. - Loggt der Container einen Rebuild, wenn du speicherst? Wenn nicht, ist es der Watcher.
- Rebuild passiert, Browser aktualisiert nicht? Dann ist es der Websocket.
Fünf Minuten davon schlagen eine Stunde zufälliges Einstellungen-Ändern, so wie ich es beim ersten Mal gemacht habe.