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, the volume-tuning topic under the broader Docker Compose environment patterns work, and it stems from how inotify/FSEvents events cross the host-to-VM boundary that Docker Compose sets up for every bind-mounted service.

The symptom is easy to misread. Developers usually assume the framework is broken, downgrade a bundler, or start restarting the container by hand — none of which touch the real cause. The watcher process is alive and healthy; it simply never receives the kernel notification that a file changed, because that notification was generated on the host and does not survive the trip through the virtualization layer. Once you understand where the event is lost, the fix is a two-line configuration change, and this page walks through diagnosing, confirming, and preventing the failure across Linux, macOS, and WSL2.

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

The touch above runs inside the container, which isolates the question: if an in-container write does not trigger the watcher, the problem is the watcher configuration itself, not the mount. That distinction saves hours — a watcher that ignores its own container-local writes is misconfigured (wrong watch path, wrong file extension filter, or a watcher that was never started), and no amount of mount tuning will fix it. Now repeat the edit from the host to expose the boundary that actually drops events:

#!/usr/bin/env bash
# edit from the HOST this time
set -euo pipefail
date > ./src/_probe.tmp
sleep 2
docker compose logs --since 5s app | grep -iE 'reload|rebuild|change' || echo "HOST EDIT NOT SEEN"

A container-side touch that reloads while a host-side write does not is the signature of events being lost in the FUSE/virtiofs layer — that single asymmetry tells you polling is required. Next, check whether the host kernel is even allowing native 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

The default max_user_watches of 8192 is shared across every process on the host, and a single large node_modules tree or monorepo can register tens of thousands of watch descriptors. When the ceiling is hit, inotify_add_watch returns ENOSPC and most watchers swallow the error silently, so the server keeps running while covering only a fraction of your files.

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). The host kernel raises the IN_MODIFY event locally, but the shared-filesystem protocol that projects your source into the Linux VM does not translate that event into an inotify notification on the container side. Native watchers inside the container therefore never fire on host-side edits, even though cat and ls show the updated file immediately — reads are served on demand, but change notifications are not pushed.

Even on native Linux, where events do cross a plain bind mount, 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. The two failure modes look identical from the application's perspective — a quiet watcher — but they have opposite fixes: the virtualization gap requires polling, while the watch-limit ceiling is solved by raising a kernel parameter and keeping native events. The diagnostic asymmetry above is what tells them apart.

The specific behaviour on exhaustion depends on the library. chokidar (webpack, Vite, nodemon) attempts one inotify_add_watch per directory; when the call returns ENOSPC it emits an error event that most dev servers log at debug level and otherwise ignore, so the process keeps serving while covering only the directories it managed to register before the ceiling. Python's watchdog raises OSError: inotify watch limit reached on some versions and silently degrades to a no-op on others. Go's fsnotify behaves similarly. None of these crash the server, which is precisely why the failure is so easy to misattribute to the bundler — the watcher is running, the port is open, and only the notifications are missing. Understanding that the watcher can be simultaneously alive and deaf is the key mental shift for diagnosing this class of bug.

Where the filesystem event is lost A host edit raises an inotify event that is dropped at the virtualization layer before it reaches the container watcher. Event Path Across the Boundary Host edit save src/app.ts kernel: IN_MODIFY VirtioFS / 9P serves reads only event dropped here Container watcher chokidar / watchdog stays silent Dashed segment is where the native notification never arrives.
The host raises the event, but the shared-filesystem layer forwards data on demand and never pushes the change notification into the container.

Resolution

  1. Switch the watcher to polling, which detects changes by stat-ing files on an interval instead of relying on kernel events. Polling always works because a stat() syscall reads current metadata through the FUSE layer on every tick, so it never depends on an event that the boundary would drop. 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. This applies only when a plain bind mount already delivers events and you have merely exhausted the descriptor budget; it does nothing on macOS or Docker Desktop, where no events cross the boundary regardless of the limit.
#!/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. A common trap is an anonymous volume declared in the image (for example VOLUME /app/node_modules) shadowing part of the tree, so the file you edit on the host and the file the watcher stats are two different inodes.
#!/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. Polling cost scales linearly with the number of stat-ed paths, so a tight ignore list is the difference between a 300ms poll that idles at near-zero CPU and one that pins a core walking a dependency tree.
// 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

The decision between polling and raising the watch limit comes down to one question: does a host-side edit ever reach the container on your platform? The tree below encodes that choice.

Polling versus raising the watch limit A decision on whether host edits reach the container, leading to either polling or raising the inotify limit. Which Fix Applies? Host edit reaches the container watcher? No (macOS / WSL2 /mnt) enable polling variables CHOKIDAR_USEPOLLING Yes but partial (Linux) raise max_user_watches keep native events
Polling is the reliable path on virtualized filesystems; raising the kernel limit only helps where native events already cross the mount.

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...

Once polling is active, reload latency is bounded by your poll interval plus rebuild time, not by an event that may never arrive. The interval is a direct trade-off: a shorter tick reloads faster but stats more often. The measurements below show the perceived time-to-reload for a small TypeScript service on VirtioFS at three common intervals.

Reload latency by poll interval Bar chart comparing perceived reload time in milliseconds for three chokidar poll intervals. Perceived Reload Time (ms) interval 1000 1180 interval 300 470 interval 100 280
A 300ms interval is the usual sweet spot — near-instant feedback with negligible idle CPU on a scoped watch.

Prevention

  1. Bake the polling variables into the dev override only (never the production image) so CI and prod keep native, event-driven behavior. A docker-compose.override.yml that Compose merges automatically in local runs is the correct home for CHOKIDAR_USEPOLLING, keeping the base file deployable unchanged. Because Compose deep-merges the override on top of the base docker-compose.yml for every docker compose up that does not pass -f explicitly, the polling environment applies locally without any developer action, while a CI job that invokes the base file alone never inherits it. This separation matters: leaving polling on in production would burn CPU on a container that has a working native watcher or, more often, no watcher at all.
  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, and assert it in a smoke check so onboarding fails loudly rather than degrading into a silent watcher.
#!/usr/bin/env bash
# onboarding assertion: fail loud if the watch budget is too small
set -euo pipefail
limit=$(cat /proc/sys/fs/inotify/max_user_watches)
if [ "$limit" -lt 262144 ]; then
  echo "inotify watch limit ${limit} too low — run scripts/raise-inotify.sh" >&2
  exit 1
fi
echo "inotify watch limit OK: ${limit}"

Platform caveats

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

Frequently Asked Questions

Why does cat show my edited file but the watcher never fires?

Because reads and change-notifications travel different paths. A cat or ls issues a stat/read syscall that the FUSE or virtiofs layer resolves on demand against the host file, so you always see current contents. A native watcher instead waits for the kernel to push an inotify event, and that push is generated on the host and never translated across the virtualization boundary. Seeing fresh content therefore tells you the mount works; it says nothing about whether events flow. Polling closes the gap by turning the watcher into a repeated stat, which uses the same on-demand path that already works.

Does polling waste CPU, and how do I keep it cheap?

Polling costs one stat syscall per watched path per interval, so cost scales with the number of files, not the size of your project. The two levers are interval and scope. Keep the interval at 300ms — fast enough to feel instant, slow enough to idle — and constrain the watch to your source directory while ignoring node_modules, dist, and .git. A scoped 300ms poll over a few hundred files is negligible; an unscoped poll over a full dependency tree walks tens of thousands of paths every tick and pins a core. Commit the ignore list so the cheap configuration is the default.

My hot reload works on Linux but not on a teammate's Mac. Why?

Native Linux bind mounts pass inotify events straight through, so an event-driven watcher works with no extra configuration. macOS and Windows run the container in a lightweight VM whose shared-filesystem protocol (VirtioFS/gRPC-FUSE) does not forward host events, so the same watcher stays silent there. This is exactly why the polling variables belong in a committed dev override rather than each developer's shell: the override makes the project behave identically on every platform, and the Linux users pay only a tiny polling cost they would otherwise avoid.

I raised fs.inotify.max_user_watches but reload still does not fire — what now?

Raising the limit only helps when events already cross the mount and you have merely run out of watch descriptors, which is a Linux-native scenario. On Docker Desktop or WSL2 with the repo under /mnt/c, no event reaches the container regardless of the ceiling, so the higher limit changes nothing. Confirm which case you are in with the diagnostic: touch a file from inside the container versus from the host. If the in-container touch reloads but the host edit does not, the boundary is dropping events and you need polling, not a bigger limit.