Hot reload through a bind mount is slow on macOS, misses file events under WSL2's /mnt/c, and drags node_modules across the VM boundary; the alternative — rebuilding the image on every change — takes forty seconds. Running docker compose watch instead fails with service "web" has no develop.watch configuration, or syncs files but never picks up a new dependency because package.json changes were synced like any other file. Compose watch, stable since Compose 2.22, syncs changed files into running containers, rebuilds when dependencies change, and restarts when configuration changes — without any bind mount. This page configures it properly, as part of multi-service orchestration with Compose.

Watch is the Compose-native answer to what Tilt's live update does for Kubernetes: file-level sync with explicit rules for when a full rebuild is needed instead.

Diagnostic

Check the Compose version, whether services define watch rules, and how file changes currently reach containers:

#!/usr/bin/env bash
set -euo pipefail
docker compose version --short
docker compose config --format json | jq '.services | to_entries[] | {service: .key, watch: (.value.develop.watch // "none"), binds: [(.value.volumes // [])[] | select(.type=="bind") | .source]}'
docker compose watch --dry-run 2>&1 | head -3 || true

Expected bad output:

2.29.2
{ "service": "web", "watch": "none", "binds": ["/Users/dev/src/shop/web"] }
{ "service": "api", "watch": "none", "binds": ["/Users/dev/src/shop/api"] }
service "web" has no develop.watch configuration

Both services rely on bind mounts of the whole source directory, and neither defines watch rules, so compose watch has nothing to do.

Bind Mount vs Compose Watch Comparison of mounting source into containers against syncing changed files with Compose watch. Bind Mount vs Compose Watch bind mount whole tree compose watch rules every access crosses VM only changes copied events unreliable on WSL host watcher, reliable node_modules shadowing image owns dependencies dependency change missed rebuild action
Watch copies only changed files and rebuilds when dependencies change.

Root cause

Bind mounts make the container read files directly from the host, so every file access — including thousands of reads of node_modules at startup — crosses the boundary between the host and the Linux VM on macOS and Windows, and file-change events may not propagate at all from some filesystems. Rebuilding on every change avoids the mount but repeats the whole image build. Compose watch sits between the two: Compose runs a file watcher on the host, and for each change applies the first matching rule from the service's develop.watch list — sync copies the file into the running container, rebuild rebuilds the image and recreates the container, sync+restart copies and then restarts the container. Without rules, watch does nothing. With a single sync rule for the whole directory, dependency manifests are synced too, but nothing reinstalls dependencies, so new packages are missing until someone rebuilds by hand.

Sync also has a direction worth understanding. It copies from the host into the container, never back. Files the container writes — generated types, database migrations created by a CLI inside the container, formatter output — stay inside the container and disappear when it is recreated. Workflows that rely on a container writing into the source tree need either a separate, narrow bind mount for that output directory or a change to run the generator on the host. Deciding this per directory, rather than keeping a whole-tree bind mount "just in case", is what gives watch its performance benefit.

Finally, watch only acts while it is running. Changes made while docker compose watch is stopped are not synced retroactively; the next up --build includes them because they are in the build context, but a container that was merely restarted still has the old files. Making up --watch the default command avoids that gap.

Resolution

  1. Remove the source bind mount and let the image contain the code and dependencies:
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
CMD ["npm", "run", "dev"]
  1. Add watch rules per service, ordered from most specific to least:
services:
  web:
    build: ./web
    ports:
      - "127.0.0.1:5173:5173"
    develop:
      watch:
        - action: rebuild
          path: ./web/package-lock.json
        - action: sync+restart
          path: ./web/vite.config.ts
          target: /app/vite.config.ts
        - action: sync
          path: ./web/src
          target: /app/src
          ignore:
            - "**/*.test.ts"
            - "**/__snapshots__/"
  api:
    build: ./api
    develop:
      watch:
        - action: rebuild
          path: ./api/requirements.txt
        - action: sync
          path: ./api/app
          target: /app/app

A lockfile change rebuilds the image so dependencies install; a config change syncs and restarts; source changes sync and the dev server's own reloader picks them up.

  1. Run the stack with watch enabled:
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --build --wait
docker compose watch

docker compose up --watch combines both steps in recent Compose versions, streaming logs and sync events in one terminal.

  1. Check that the dev server reloads on synced files. Frameworks that watch with native file events inside the container see synced files as normal writes; no polling configuration is needed because the files are written inside the container's own filesystem.
What Compose Watch Does on a Change Ordered decision steps Compose watch applies to each changed file. What Compose Watch Does on a Change 1 — host watcher sees a change 2 — match rules in order 3 — rebuild if lockfile changed 4 — sync and restart if config changed 5 — sync into container otherwise
The first matching rule wins, so specific paths go before broad ones.

Expected output

$ docker compose watch
[+] Watching web, api
Syncing "web" after changes were detected
  - /Users/dev/src/shop/web/src/cart/Total.tsx
Rebuilding service "web" after changes were detected
  - /Users/dev/src/shop/web/package-lock.json
[+] Running 1/1
 ✔ Container shop-web-1  Started

Source edits appear in the browser within a second through the dev server's reload, and adding a dependency triggers one image rebuild, after which the new package is available.

On macOS the difference is most visible at startup: without the bind mount, the dev server reads node_modules from the container's own filesystem, so the first page load after up is typically several times faster. Under WSL2 with a repository on the Windows drive, watch also fixes missed reloads, because the watcher runs on the host side where file events are reliable and the container only receives the resulting writes.

Prevention

  1. Put a rebuild rule on every dependency manifestpackage-lock.json, requirements.txt, go.sum, Gemfile.lock — so dependency changes can never be silently synced without installation.

  2. Keep watch rules in the local override file (compose.override.yaml) if CI uses the same base, so CI does not carry development-only configuration; see layering Compose override files for local and CI.

  3. Document docker compose up --watch as the default development command in the README and task runner, so developers do not fall back to bind mounts out of habit.

Time From Save to Browser Update Bar chart of time from saving a file to seeing the change for three approaches on macOS. Time From Save to Browser Update rebuild image each change 41 s bind mount on macOS 1.9 s compose watch sync 0.6 s
Measured on a Vite frontend; watch avoids both the bind-mount overhead and full rebuilds.

Platform caveats

macOS (Docker Desktop): watch removes the bind-mount performance penalty entirely for synced paths, which is the main reason to adopt it on macOS.

WSL2: watch works from both the Linux filesystem and /mnt/c, because the watcher runs on the host side; performance is still better with the repository in the Linux filesystem.

Apple Silicon (ARM64): rebuild actions build for the native platform; builds for other platforms are not triggered by watch.

Compose version: develop.watch needs Compose 2.22+; sync+restart and up --watch need 2.23+. Pin the minimum in the toolchain file.

Rollback

Restore the bind mount and remove the develop section; docker compose up behaves as before:

#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- compose.yaml web/Dockerfile
docker compose up -d --build --force-recreate

Frequently Asked Questions

Why does docker compose watch say the service has no watch configuration?

Watch only acts on services with a develop.watch section. Add rules with action, path and, for sync actions, target.

Does watch replace bind mounts completely?

For source code, yes. Keep bind mounts only where a container must write files back to the host, such as generated code you want to commit; watch syncs one way, from host to container.

Why is a new npm package missing after I added it?

The lockfile change was synced rather than triggering a rebuild. Add a rebuild rule for the lockfile before the broader sync rule, since the first matching rule wins.

Can watch restart a service instead of rebuilding it?

Yes, sync+restart copies the changed file and restarts the container, which suits configuration files that the process reads only at startup.