For years my local container workflow had a tax on every change: edit a file, switch to the terminal, run docker compose up --build, wait thirty seconds to two minutes, then check the browser. Multiply that by the eighty or so changes I make in a focused afternoon and I was burning serious time just waiting on rebuilds. Docker Compose Watch removes that tax almost entirely, and it ships in the Compose binary you already have.
The short answer for anyone searching: add a develop.watch block to your compose.yaml, declare which paths should sync, rebuild, or sync+restart, and run docker compose up --watch. Compose Watch went GA in Compose v2.22.0 (bundled with Docker Desktop 4.24), so if you have updated Docker any time since late 2023, you already have it. In this post I will walk through how I use it across a NestJS API and worker setup, where it beats bind mounts, and the gotchas that bit me in real projects.
Why the Old Local Dev Loop Was Slow
Before Watch, you had two options for live code in containers, and both had sharp edges:
- Full rebuilds on every change. Correct but painfully slow, especially for Node projects where npm install dominates the build even with good layer caching.
- Bind mounts of your source directory. Fast, but they drag your host's node_modules, OS-specific binaries, and editor temp files into the container, causing the classic works-on-my-machine drift you adopted containers to avoid.
- On macOS and Windows, bind mounts also pay a filesystem translation penalty through the VM, which makes file-heavy operations noticeably slower than native Linux.
Compose Watch takes a third path: it observes your host files and performs a one-way copy of changes into the running container. Your image stays the single source of truth for dependencies and system libraries, while your source code flows in at save-time. Container-side changes never leak back to your host, which is exactly the direction of trust you want in development.
The Three Watch Actions: sync, rebuild, sync+restart
Every watch rule pairs a path with an action. Choosing the right action per path is where the real speedup lives, because the cheapest action that still gives you a correct container wins.
| Action | What it does | When I use it |
|---|---|---|
| sync | Copies changed files into the running container at the target path, without restarting anything. | Source code in anything with hot reload: NestJS with webpack HMR, Next.js, Vite, nodemon-watched scripts. |
| rebuild | Rebuilds the image with BuildKit and replaces the running container, like docker compose up --build for that service. | package.json, lockfiles, Dockerfile, anything baked into image layers. Also compiled languages like Go where the binary is the artifact. |
| sync+restart | Copies the changed files, then restarts the container process without rebuilding the image. | Config files read once at boot: .env files, nginx.conf, ormconfig. The image is fine, the process just needs a fresh start. |
A Real compose.yaml With Watch Rules
Here is a trimmed version of the setup I run for a typical NestJS API plus background worker. Note how each path gets the cheapest viable action, and how ignore keeps test files from triggering pointless syncs:
# compose.yaml — NestJS API + worker, watch-enabled
services:
api:
build: ./api
ports:
- "3000:3000"
develop:
watch:
# hot-reload source straight into the container
- path: ./api/src
target: /app/src
action: sync
# dependency changes need a real rebuild
- path: ./api/package.json
action: rebuild
# config tweak? just restart the process
- path: ./api/.env.development
action: sync+restart
target: /app/.env.development
worker:
build: ./worker
develop:
watch:
- path: ./worker/src
target: /app/src
action: sync
ignore:
- "**/*.spec.ts"Then start everything with watch enabled:
docker compose up --watch
# or, to run watch without attaching logs:
docker compose watchPro tip: keep using docker compose up --watch (one terminal, logs and watch together) rather than a separate watch session, until your stack grows. With more than three or four services, split logs into another terminal with docker compose logs -f api so sync notifications do not drown your application output.
Compose Watch vs Bind Mounts: Which One Wins
I still get asked why not just mount the source directory and call it a day. Here is the honest comparison after running both approaches across client projects and my own VPS-deployed apps:
Compose Watch
One-way host-to-container sync. node_modules stays the container's own, built for Linux inside the image. Editor temp files are filtered automatically, and rapid saves are debounced into batched transfers. Dev environment matches production image behavior closely.
Bind mounts
Two-way by default, so container writes pollute your host tree. Host node_modules shadows the image's unless you add anonymous volume hacks. Slower I/O through the macOS/Windows VM boundary. Still fine for quick experiments and single-service projects.
My rule of thumb: bind mounts for throwaway prototyping, Compose Watch for anything with a Dockerfile that will eventually ship. The sync action gives you bind-mount-level feedback speed while preserving the image as the contract, and that contract is what makes the jump from laptop to my Docker Swarm production nodes boring, which is the goal.
Gotchas That Cost Me an Afternoon
- Watch only works on services with a build attribute. If a service only has image, Compose has nothing to rebuild or sync against and the watch section is ignored.
- The container image needs stat, mkdir, and rmdir binaries for sync to work. Distroless and some ultra-minimal images do not have them, so keep a dev variant of your Dockerfile based on the slim or alpine tags.
- The container user must be able to write to the sync target. If your Dockerfile drops to a non-root user (it should in production), make sure that user owns the target directory in your dev stage, or syncs silently fail.
- Paths in ignore are relative to the watched path, not the project root. I lost time wondering why my dist exclusion did nothing until I re-read the docs.
Do not point a sync action at package.json thinking it will install dependencies. Sync only copies files; node_modules in the container will not change. Dependency manifests belong under the rebuild action, full stop.
Adoption Checklist: Migrating a Project in 15 Minutes
- Run docker compose version and confirm you are on v2.22.0 or newer; upgrade Docker Desktop or the Compose plugin if not.
- Delete source-code bind mounts from your compose file, keeping only named volumes for databases and caches.
- Add a develop.watch block per service: sync for hot-reloadable source, rebuild for dependency manifests and the Dockerfile, sync+restart for boot-time configs.
- Add ignore entries for test files, build output, and anything your hot reloader does not need.
- Run docker compose up --watch, change a file, and verify you see the sync notification followed by your framework's hot reload log line.
The Takeaway
Compose Watch is one of those quality-of-life features that quietly pays for itself within the first hour. My measured loop on a mid-size NestJS codebase went from roughly 45 seconds per change with rebuilds to under two seconds with sync plus webpack HMR, and unlike bind mounts I did not trade away environment fidelity to get there.
If your team still rebuilds images on every save, or fights node_modules ghosts from bind mounts, spend the fifteen minutes. The configuration surface is three actions and an ignore list. That is the whole feature, and it is enough.
Sources and further reading

