Fixing Hot Reload Not Triggering on File Changes
Fix hot reload that never fires inside a container. Covers inotify limits, polling fallbacks with CHOKIDAR_USEPOLLING, bind-mount consistency, and WSL2/macOS specifics.
Fixing Hot Reload Not Triggering on File Changes
You save a file on the host, but the dev server in the container never rebuilds — no reload, no log line, nothing — because the container's file watcher is not receiving filesystem events across the bind mount. This is the most common failure mode in Volume Mounting & Hot-Reload Optimization, and it stems from how inotify/FSEvents events cross the host-to-VM boundary.
Diagnostic
Edit a watched file and confirm the container sees nothing:
#!/usr/bin/env bash
# does the watcher react?
set -euo pipefail
docker compose exec app sh -c 'touch /app/src/_probe.tmp'
docker compose logs --since 10s app | grep -iE 'reload|rebuild|change' || echo "NO WATCHER EVENT"
# BAD: file changed, watcher silent
NO WATCHER EVENT
Check whether the host kernel is even allowing watches:
#!/usr/bin/env bash
set -euo pipefail
cat /proc/sys/fs/inotify/max_user_watches
docker compose exec app sh -c 'find /proc/*/fd -lname "anon_inode:inotify" 2>/dev/null | wc -l'
# BAD: limit exhausted — large repos blow past 8192
8192
Root cause
inotify events do not propagate from the host across Docker Desktop's virtualization layer (gRPC-FUSE/VirtioFS on macOS and Windows, 9P/virtiofs on WSL2). Native watchers inside the container therefore never fire on host-side edits. Even on Linux, a low fs.inotify.max_user_watches limit silently caps the number of files a watcher can observe, so large source trees register zero or partial events.
Resolution
- Switch the watcher to polling, which detects changes by stat-ing files on an interval instead of relying on kernel events. Tool-specific variables cover the common runtimes.
# docker-compose.yml
services:
app:
image: node:20-alpine
environment:
- CHOKIDAR_USEPOLLING=true # webpack, Vite, nodemon (chokidar)
- CHOKIDAR_INTERVAL=300
- WATCHPACK_POLLING=true # webpack 5 / Next.js
- WATCHDOG_USE_POLLING=true # Python watchdog / uvicorn --reload
volumes:
- ./src:/app/src:cached
- If you want to keep native events on Linux, raise the host watch limit instead of polling — it is far cheaper on CPU.
#!/usr/bin/env bash
# raise the host limit (Linux / WSL2 host kernel)
set -euo pipefail
sudo sysctl -w fs.inotify.max_user_watches=524288
echo 'fs.inotify.max_user_watches=524288' | sudo tee /etc/sysctl.d/60-inotify.conf
- Verify the bind mount is actually mounting your source — a missing or shadowed mount looks identical to a dead watcher.
#!/usr/bin/env bash
set -euo pipefail
docker compose exec app sh -c 'ls -la /app/src && stat -c "%n %Y" /app/src/* | head'
- Tighten the watch scope so polling stays cheap: ignore
node_modules, build output, and VCS directories.
// nodemon.json
{
"watch": ["src"],
"ignore": ["node_modules", "dist", ".git"],
"ext": "ts,js,json"
}
- Restart the service and re-probe.
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --force-recreate app
Expected output
app-1 | [nodemon] restarting due to changes...
app-1 | [nodemon] starting `node dist/index.js`
docker compose exec app sh -c 'touch /app/src/_probe.tmp'
docker compose logs --since 10s app | grep -i restart
# app-1 | [nodemon] restarting due to changes...
Prevention
- Bake the polling variables into the dev override only (never the production image) so CI and prod keep native, event-driven behavior.
- Commit the watch-ignore config (
nodemon.json,vite.configserver.watch) so a stray developer setting cannot silently re-enable a full-tree poll that pins a CPU core. - Document the host
inotifylimit in your bootstrap script so a fresh machine raises it automatically.
macOS (Docker Desktop): VirtioFS does not forward
FSEventsinto the container; polling is the reliable path.:cachedmounts reduce stat latency so a 300ms poll feels instant. WSL2: keep the repo on the Linux filesystem (~/code, not/mnt/c); files under/mnt/cnever emit inotify events into the distro, so native watching cannot work there at all. Apple Silicon (ARM64):chokidar/watchdogmay fall back to polling onglibcvsmuslmismatches; pin base images to the variant matching your toolchain.
Rollback
#!/usr/bin/env bash
set -euo pipefail
git checkout -- docker-compose.yml nodemon.json 2>/dev/null || true
docker compose up -d --force-recreate app