Skip to content

Fix: Docker Compose Watch Not Working — sync vs rebuild, Ignore Patterns, WSL/macOS File Events

FixDevs · (Updated: )

Part of:  Docker, DevOps & Infrastructure

Quick Answer

How to fix docker compose watch errors — develop.watch directive not firing, sync vs sync+restart vs rebuild differences, ignore globs not matching, WSL2 file events delayed, named volumes shadowing watch, and Compose version requirements.

The Error

You add a develop.watch block and docker compose up --watch runs, but file changes don’t propagate:

services:
  app:
    build: .
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src

You edit ./src/index.ts and nothing happens in the container.

Or sync+restart triggers a restart loop:

[+] Restarting service "app"
[+] Restarting service "app"
[+] Restarting service "app"

Or --watch exits with:

the docker compose version is too old to support watch

Or watching works on macOS but not on Windows / WSL2:

# Files saved in VS Code on Windows.
# Container's /app/src is unchanged.

Why This Happens

docker compose watch is a developer-experience layer on top of bind mounts. It detects file changes on the host, then performs one of three actions:

  • sync — copy changed files into the running container’s filesystem.
  • sync+restart — sync, then restart the container (for code that runs once at startup).
  • rebuild — rebuild the image and recreate the container (for Dockerfile or build-context changes).

Three sources of pain:

  • path / target mismatch. The path is on your host; target is in the container. A path that doesn’t match a real file location or a target that conflicts with a volume produces silent no-ops.
  • WSL2 and Docker Desktop file events. Editing files on Windows (C:\...) while the container runs against a WSL filesystem (or vice versa) means the file change events cross filesystem boundaries. Some configurations don’t deliver inotify events reliably.
  • Compose version requirements. develop.watch requires Compose v2.22+ and a recent Docker Engine. Older versions silently ignore the section or print the “too old” error.

There’s also a deeper architectural reason watch behaves differently from a plain bind mount. With a bind mount, every read inside the container goes through the host filesystem via the Docker virtualization layer (gRPC-FUSE on macOS, 9P/virtiofs on Windows). With develop.watch, the file lives natively inside the container’s overlay filesystem; the host change is copied in by the Compose CLI after an inotify event fires on the host. That difference matters during incidents: a slow bind mount stalls every container syscall on macOS large repos, while a broken watch causes silent staleness — the container keeps serving the previous version of the code with no error in logs.

The blast radius when watch breaks is contained to your local development loop, but the failure mode is corrosive. Your edits look saved on disk, the container is healthy, the dev server reports no errors — but the running process holds a stale module cache. You ship a “fixed” bug to a teammate’s PR review, and they reproduce the original bug on their CI run. The longer this divergence persists undetected, the more your “works on my machine” reports grow. Treat a silently broken watch like a silently broken cache invalidation: assume some fraction of your recent debugging has been against stale code, and rebuild from scratch (docker compose down && docker compose up --build --watch) the moment you notice anything inconsistent between save and observed behavior.

Fix 1: Verify Compose Version

docker compose version
# Docker Compose version v2.27.0  ← need v2.22 or later

If the version is older, update Docker Desktop (Mac/Windows) or install the latest compose plugin on Linux:

# Linux — install the latest from Docker's apt repo
sudo apt-get update
sudo apt-get install docker-compose-plugin

develop.watch was promoted from experimental in Compose v2.22 (late 2023). Anything older needs an upgrade.

Fix 2: Pick the Right Action for the Change Type

Different file changes need different actions:

services:
  app:
    build: .
    develop:
      watch:
        # Source code that hot-reloads inside the container (Vite, nodemon, etc.):
        - action: sync
          path: ./src
          target: /app/src
          ignore:
            - "**/*.test.ts"
            - "node_modules/"

        # Config files the app reads at startup — sync + restart:
        - action: sync+restart
          path: ./config
          target: /app/config

        # Dockerfile changes need a rebuild:
        - action: rebuild
          path: ./Dockerfile

        # Lockfile changes — rebuild to pick up new deps:
        - action: rebuild
          path: ./package-lock.json

sync alone is what you want for most app source code if your dev process (Vite, Next.js, nodemon, watchexec) hot-reloads on file changes. sync+restart is for code that runs at startup and doesn’t watch its own files.

Pro Tip: Don’t use rebuild for source files. It blows away the container state and is much slower than sync. Reserve rebuild for Dockerfile, package.json (dep changes), or lockfiles.

Fix 3: Match path to Real Locations

path is a directory or file on the host, relative to the compose file:

develop:
  watch:
    - action: sync
      path: ./src          # → ./src/* events
      target: /app/src     # → copied to /app/src inside the container

For multiple discrete paths, list them separately:

develop:
  watch:
    - action: sync
      path: ./apps/web/src
      target: /app/apps/web/src
    - action: sync
      path: ./packages/shared/src
      target: /app/packages/shared/src

The compose file’s directory is the base. ./src means “src relative to docker-compose.yml” — not relative to wherever you ran docker compose up.

Common Mistake: Mapping path: ./src to target: /app (no /src). The sync writes files directly into /app, potentially overwriting files outside the source tree. Always make target mirror the source structure inside the container.

Fix 4: Use ignore to Skip Unwanted Triggers

Without an ignore list, Compose watches every file in path, including node_modules/, .git/, build artifacts, and editor temp files. These often trigger thousands of useless sync events:

develop:
  watch:
    - action: sync
      path: ./
      target: /app
      ignore:
        - "node_modules/"
        - ".git/"
        - "dist/"
        - "*.log"
        - "**/__pycache__/"
        - ".venv/"
        - ".pytest_cache/"

Patterns use gitignore-style globs. node_modules/ matches at any depth; **/__pycache__/ matches in subdirectories explicitly.

Pro Tip: Mirror your .gitignore. If you cat .gitignore and convert each line to a Compose ignore, you’ll catch nearly every false-positive event.

Fix 5: Avoid Conflicts With Bind Mounts and Volumes

If you already have a bind mount and add develop.watch on the same path, the two mechanisms fight each other:

services:
  app:
    volumes:
      - ./src:/app/src      # Bind mount — files are live-shared
    develop:
      watch:
        - action: sync
          path: ./src       # ALSO sync — redundant, can race
          target: /app/src

Pick one:

  • Bind mount only — fastest, native file sharing. Use when host/container filesystems play nicely.
  • develop.watch sync only — copies files via Compose. Use when bind mounts are slow (macOS) or the container needs different file ownership.

For named volumes that shadow your watched path:

services:
  app:
    volumes:
      - node_modules:/app/node_modules   # Named volume — survives restarts
    develop:
      watch:
        - action: sync
          path: ./
          target: /app
          ignore:
            - "node_modules/"            # Don't sync node_modules

Without ignoring node_modules, the watch tries to sync it into the named volume, which doesn’t go well.

Fix 6: WSL2 and File Event Reliability

If you edit files on Windows (C:\code\...) and the container watches a path mounted from \\wsl$\Ubuntu\..., file events can be delayed or dropped. Two safer patterns:

Put the project entirely inside WSL:

# In WSL:
cd ~/code
git clone https://github.com/...
docker compose up --watch

Then edit files via VS Code’s Remote-WSL extension or directly inside WSL. File events stay within one filesystem.

On macOS, prefer Compose watch over bind mounts for large repos:

Bind mounts on macOS go through a virtualization layer and are slow for large file trees. develop.watch sync is one-way (host → container) and faster.

Common Mistake: Mounting Windows paths into Linux containers via C:\ instead of /c/. Docker Desktop translates, but file watcher behavior is inconsistent across CLI vs Docker Desktop UI launches. Stick to WSL paths.

Fix 7: Restart Strategy When sync+restart Loops

A restart loop usually means:

  • The container exits quickly after restart (check docker compose logs app for the actual error).
  • Your code re-saves a watched file at startup (creates a feedback loop).
  • A chmod or chown during startup triggers Compose to see a “change” event.

To diagnose:

docker compose logs --tail 50 app

Look for the last error before the restart. If the container can’t start (missing env var, crash), sync+restart will keep restarting it.

If the issue is your app re-saving a watched file at startup, add it to ignore:

ignore:
  - "src/generated/"
  - "src/version.ts"  # Auto-regenerated on each start

Fix 8: Combine With docker compose up --build --watch

For complex changes (deps + source), launch with both --build and --watch:

docker compose up --build --watch
  • --build rebuilds at startup (catching changes since the last build).
  • --watch keeps watching after.

For CI-friendly one-off runs without watching:

docker compose up -d
docker compose watch  # Detached watch in foreground

Or run watch alongside the existing stack:

docker compose watch app frontend  # Watch only specific services

Production Incident Lens: Dev Loop Outages Are Real Incidents

It’s tempting to dismiss a broken watch as “just dev friction,” but on a team of five or more engineers it behaves like a real incident. Every engineer who hits stale-file confusion loses 15-30 minutes diagnosing whether the bug is in their code, the cache, or the watch directive. Multiply by a team and a week, and a broken develop.watch block on main can burn an engineer-day before anyone files a fix.

Treat the dev loop with the same SLO discipline you’d apply to a production service. Before merging changes to docker-compose.yml or compose.dev.yml, run docker compose down -v && docker compose up --build --watch on a fresh clone and edit one file in each watched path to confirm propagation. If you maintain a dev environment for a larger team, add a make verify-watch target that does exactly this. The cost of one extra minute per merge is trivial compared to a broken dev loop blocking five engineers from shipping.

The CI-vs-local divergence problem deserves its own SLO. When watch breaks silently, your local container holds different code than your CI runner — your tests pass against stale modules locally while CI runs against the actual git state. A green local test followed by red CI is the classic signature. Add a sanity step at the top of your dev loop that prints the SHA of a known file from inside the container (docker compose exec app sha1sum /app/src/index.ts) and compares it to the host. Wire that comparison into your dev container’s healthcheck so a stale sync surfaces as an unhealthy container, not as wasted debugging time.

Still Not Working?

A few less-obvious failures:

  • watch enabled but nothing logs on file change. Run with --verbose: docker compose up --watch --verbose. Look for [watch] lines confirming the directives were parsed.
  • Build context too large during rebuild. Add a .dockerignore. Without it, every rebuild re-uploads node_modules/, .git/, etc. to the daemon.
  • sync succeeds but the container still serves stale files. Your dev server isn’t watching the synced path. Check that Vite/Next/nodemon’s watch root matches the target.
  • Container has wrong file ownership after sync. develop.watch copies as the user the daemon runs as. Set user: in the compose file or USER in the Dockerfile to align with the host UID.
  • macOS: spinning beachball when watching a node_modules-heavy project. Use a named volume for node_modules and add node_modules/ to ignore.
  • develop.watch fires twice for one save. Editor saves write a temp file, then rename — two events. Add *.tmp and editor-specific patterns to ignore.
  • Apple Silicon (ARM) builds slow. Specify platform: linux/arm64 in your service to avoid emulation. rebuild actions go from minutes to seconds.
  • watch works but restart doesn’t actually restart the process. The container has a restart: no policy, and Compose’s restart is in-container. Set restart: unless-stopped for the service, or use command: to wrap your process with a supervisor that restarts on signal.
  • Watch silently stops after a host sleep/resume. macOS and Windows pause inotify-equivalent subsystems when the host sleeps. The Compose CLI doesn’t always reattach cleanly. If watch was working before lunch and isn’t now, Ctrl+C and relaunch docker compose up --watch before debugging anything else.
  • CI uses a different compose file than local. Many teams have compose.yml + compose.dev.yml, and CI only loads the base file. Run docker compose config and diff it across environments to confirm both pipelines see the same develop.watch directives.
  • VS Code Dev Containers re-mount over your watch target. The Dev Container extension can add its own bind mount at /workspaces/... that shadows your watched target. Check docker inspect <container> for unexpected mounts before assuming Compose is at fault.

For related Docker development workflow issues, see Docker Compose env not loaded, Docker Compose service failed to build, Docker Compose healthcheck not working, and Docker Compose networking not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles