Using Compose Watch for Sync and Rebuild
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.
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
- 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 npm ci
COPY . .
CMD ["npm", "run", "dev"]
- 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.
- 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.
- 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.
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
Put a
rebuildrule on every dependency manifest —package-lock.json,requirements.txt,go.sum,Gemfile.lock— so dependency changes can never be silently synced without installation.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.Document
docker compose up --watchas the default development command in the README and task runner, so developers do not fall back to bind mounts out of habit.
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):
rebuildactions build for the native platform; builds for other platforms are not triggered by watch.
Compose version:
develop.watchneeds Compose 2.22+;sync+restartandup --watchneed 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.