Compose Watch and Live Reload for Local Development
TL;DR — Compose Watch (v2.6+) is
docker compose watch. Definedevelop.watchactions per service: sync files into container, or rebuild on changes, or restart. Replaces fragile bind-mount + nodemon setups. Especially good for cross-platform (Mac filesystem) performance.
Bind mounts + file-watching dev servers are the standard pattern. They work, but they’re fragile: Mac’s filesystem is slow under heavy I/O, polling-vs-events differs per OS, node_modules synchronization is a nightmare. Compose Watch is Docker’s answer.
What Compose Watch does
A new top-level develop: section per service:
services:
api:
build:
context: .
target: dev
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: ./package.json
Run docker compose watch. Compose monitors the paths and reacts:
sync— copies changed files into the container without restartingrebuild— rebuilds the image and recreates the containerrestart— restarts the container (no rebuild)
Apps with their own file watching (nodemon, air for Go, hot-reload tools) react to the synced files normally.
Three actions, three use cases
sync — for code that hot-reloads. Source files copied; dev server inside container picks up the change. Faster than bind mounts because Compose Watch uses native fs events (notify on Linux, fsevents on Mac).
develop:
watch:
- action: sync
path: ./src
target: /app/src
rebuild — for dep changes. package.json, go.sum, requirements.txt. Container needs to rebuild from scratch.
develop:
watch:
- action: rebuild
path: ./package.json
restart — for config changes. Restart picks up new env, new config files.
develop:
watch:
- action: restart
path: ./config.json
Per-language setups
Node + Vite/Webpack:
services:
bff:
build:
context: ./services/bff
target: dev
command: ["npm", "run", "dev"]
ports: ["3000:3000"]
develop:
watch:
- action: sync
path: ./services/bff/src
target: /app/src
- action: sync
path: ./services/bff/public
target: /app/public
- action: rebuild
path: ./services/bff/package.json
npm run dev runs Vite. Vite watches /app/src. Compose syncs file changes; Vite hot-reloads them.
Go + air:
services:
api:
build:
context: ./services/api
target: dev
command: ["air"]
ports: ["8080:8080"]
develop:
watch:
- action: sync
path: ./services/api
target: /app
- action: rebuild
path: ./services/api/go.mod
- action: rebuild
path: ./services/api/go.sum
air rebuilds and reruns the binary on .go file changes. Compose syncs; air rebuilds.
Python:
services:
etl:
build:
context: ./services/etl
target: dev
command: ["python", "-m", "watchgod", "etl.main.run"]
develop:
watch:
- action: sync
path: ./services/etl/etl
target: /app/etl
- action: rebuild
path: ./services/etl/poetry.lock
watchgod restarts the process on Python file changes. Same pattern.
Why this beats bind mounts
Three problems bind mounts have:
- Mac/Windows fs perf. Mac’s bind mounts have measurable I/O overhead. Reading a node_modules directory through the bind is 5-10× slower than native.
- node_modules / vendor synchronization. Bind mount overlays host directory; if host doesn’t have node_modules (or has stale ones), the container’s get clobbered. Anonymous volume hack fixes it but is fragile.
- Permissions weirdness. Container user vs host user file ownership conflicts.
Compose Watch sidesteps these. The container has its own filesystem; Compose pushes changes in. Like rsync-from-the-outside.
When bind mounts are still right
Compose Watch isn’t perfect for every case:
- Database migrations during dev. If you want to edit a SQL migration and have the dev container see it immediately, bind mount is faster.
- Generated files going OUT of the container. Vite generates HMR sockets; some tools expect to write source maps to the host. Watch is one-way.
- Tools that expect the source to be a real git directory. Watch syncs file content but not git metadata.
Use bind mounts for these specific cases; Compose Watch for the source code itself.
The watch command
docker compose watch
Brings up the stack and starts watching. Print stream of file changes and actions:
[+] Running 4/4
✔ Container my-stack-postgres-1 Started
✔ Container my-stack-redis-1 Started
✔ Container my-stack-api-1 Started
✔ Container my-stack-bff-1 Started
watch enabled
api → file changed: src/handler.go
sync action
bff → file changed: package.json
rebuild action
building bff...
Ctrl+C stops watching but leaves containers running.
Performance reality
For a typical Go API:
- Bind mount + air: ~2.1s rebuild on save
- Compose Watch sync + air: ~0.4s rebuild on save
For a Node app:
- Bind mount + Vite HMR: ~600ms hot update
- Compose Watch sync + Vite HMR: ~150ms hot update
The differences are bigger on Mac than Linux because Mac’s bind-mount overhead is bigger.
Common Pitfalls
Forgetting develop: is separate from volumes:. Don’t mix bind mounts and watch sync on the same path — confusing semantics.
Syncing everything including dist/build output. Pollutes the container with stale builds. Sync source only; rebuild generates fresh artifacts.
Watch on huge directories. Watching node_modules would constantly trigger. Scope path: to source dirs only.
Expecting watch to work without docker compose watch. Regular up doesn’t start watching. Must use the watch subcommand.
Watch + bind mount on the same target. Effectively a race. Compose may sync, then the bind mount overlays. Pick one per path.
No fallback for non-Watch users. Some team members on older Compose; their up works but no live reload. Provide a make watch target that requires v2.6.
Wrapping Up
Compose Watch is the dev-loop improvement of 2022 for containerized local dev. Defines per-service sync/rebuild/restart rules; replaces fragile bind-mount setups. Faster, especially on Mac. Friday: volumes vs bind mounts — the storage primitive these patterns are built on.