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

  1. 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
  1. 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
  1. 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'
  1. 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"
}
  1. 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

  1. Bake the polling variables into the dev override only (never the production image) so CI and prod keep native, event-driven behavior.
  2. Commit the watch-ignore config (nodemon.json, vite.config server.watch) so a stray developer setting cannot silently re-enable a full-tree poll that pins a CPU core.
  3. Document the host inotify limit in your bootstrap script so a fresh machine raises it automatically.

macOS (Docker Desktop): VirtioFS does not forward FSEvents into the container; polling is the reliable path. :cached mounts reduce stat latency so a 300ms poll feels instant. WSL2: keep the repo on the Linux filesystem (~/code, not /mnt/c); files under /mnt/c never emit inotify events into the distro, so native watching cannot work there at all. Apple Silicon (ARM64): chokidar/watchdog may fall back to polling on glibc vs musl mismatches; 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