53.10 Compose Watch vs nodemon and the node_modules volume trick
Friends, when you develop inside a container, two questions come every day. How do I see my code change without rebuilding? And why does my app crash with a missing module after I mount my folder? Today we answer both: bind mount plus nodemon, Compose Watch, and the anonymous volume trick that protects node_modules.
मित्रांनो, container मध्ये develop करताना रोज दोन प्रश्न येतात. Rebuild न करता code बदल कसा दिसेल? आणि folder mount केल्यावर missing module म्हणून app crash का होतो? आज दोन्हींची उत्तरं: bind mount आणि nodemon, Compose Watch, आणि node_modules वाचवणारी anonymous volume trick.
दोस्तों, container में develop करते समय रोज़ दो सवाल आते हैं. Rebuild किए बिना code का बदलाव कैसे दिखे? और folder mount करने पर missing module कहकर app crash क्यों होता है? आज दोनों के जवाब: bind mount और nodemon, Compose Watch, और node_modules बचाने वाली anonymous volume trick.
1. Start from a story
At Razorpay, a new developer, Sneha, joined the checkout team. Her laptop was a Mac; the container ran Linux. On day one she mounted her project folder into the container to get live reload.
The app crashed at once: Error: Cannot find module express. Her mounted folder had no node_modules, and it had hidden the node_modules that the image had installed.
She ran npm install on her Mac to fix it. Now the folder had node_modules built for macOS, and a native package called bcrypt crashed inside Linux.
Her mentor showed her two clean fixes: an anonymous volume that keeps the image's node_modules, or Compose Watch, which syncs only source files. Today we try both, and compare them with nodemon.
2. Live reload with nodemon
Step 1: What nodemon does
Why: Restart Node when files change.
What: nodemon watches files and restarts the node process on save. It is a dev dependency, not for production.
Example 1: Install
Type this:
npm install --save-dev nodemon@3.1.7 && npx nodemon --version
You will see:
3.1.7
What it means: Pinned version.
Example 2: Script
Type this:
npm pkg set scripts.dev='nodemon --legacy-watch src/server.js' && npm pkg get scripts.dev
You will see:
"nodemon --legacy-watch src/server.js"
What it means: --legacy-watch uses polling, which works through Docker mounts.
Example 3: Run locally
Type this:
npm run dev
You will see:
[nodemon] watching path(s): *.*
[nodemon] starting `node src/server.js`
listening on 3000
What it means: Restarts on every save.
Step 2: Dev Dockerfile target
Why: One Dockerfile, two stages.
What: A dev stage installs all dependencies and runs nodemon; the prod stage installs only production ones.
Example 4: Dockerfile
Type this:
printf 'FROM node:20-alpine AS base\nWORKDIR /app\nCOPY package*.json ./\n\nFROM base AS dev\nRUN npm ci\nCMD ["npm","run","dev"]\n\nFROM base AS prod\nRUN npm ci --omit=dev\nCOPY src ./src\nUSER node\nCMD ["node","src/server.js"]\n' > Dockerfile && grep -c FROM Dockerfile
You will see:
3
What it means: Three stages.
Example 5: Build dev
Type this:
docker build --target dev -t shop/api:dev . | tail -1
You will see:
naming to docker.io/shop/api:dev done
What it means: Has nodemon.
Example 6: Build prod
Type this:
docker build --target prod -t shop/api:1.4 . && docker run --rm shop/api:1.4 ls node_modules | grep -c nodemon
You will see:
0
What it means: No dev tools in prod.
Step 3: Bind mount the source
Why: Your files, inside the container.
What: Mount ./src into /app/src so nodemon sees every save.
Example 7: Compose
Type this:
printf 'services:\n api:\n build: {context: ., target: dev}\n ports: ["3000:3000"]\n volumes: ["./src:/app/src"]\n' > compose.yaml && docker compose up -d && echo up
You will see:
up
What it means: Only src is mounted, so node_modules is safe.
Example 8: Edit
Type this:
printf 'console.log("hello v2")\n' >> src/server.js && sleep 2 && docker compose logs --tail 2 api
You will see:
api-1 | [nodemon] restarting due to changes...
api-1 | hello v2
What it means: Restart in under two seconds.
Example 9: Polling env
Type this:
printf ' environment:\n CHOKIDAR_USEPOLLING: "true"\n' >> compose.yaml && echo set
You will see:
set
What it means: Some watchers need polling on Mac and Windows mounts.
3. The node_modules problem and the trick
Step 4: Mount the whole project
Why: See the crash yourself.
What: Mounting . over /app hides the image's /app/node_modules.
Example 10: Whole folder
Type this:
printf 'services:\n api:\n build: {context: ., target: dev}\n volumes: [".:/app"]\n' > compose.yaml && docker compose up -d && sleep 2 && docker compose logs --tail 1 api
You will see:
api-1 | sh: nodemon: not found
What it means: node_modules is hidden.
Example 11: Look inside
Type this:
docker compose run --rm api ls /app | grep -c node_modules
You will see:
0
What it means: The folder is not there.
Example 12: Exit code
Type this:
docker compose ps -a --format '{{.Service}} {{.State}} {{.ExitCode}}'
You will see:
api exited 127
What it means: 127 means command not found.
Step 5: Anonymous volume for node_modules
Why: Keep the image's modules.
What: Add a second volume line with only a container path. Docker creates an anonymous volume there, filled from the image, which sits above the bind mount.
Example 13: Fix
Type this:
printf 'services:\n api:\n build: {context: ., target: dev}\n volumes:\n - .:/app\n - /app/node_modules\n' > compose.yaml && docker compose up -d && sleep 2 && docker compose logs --tail 1 api
You will see:
api-1 | listening on 3000
What it means: Works again.
Example 14: See the volume
Type this:
docker volume ls --format '{{.Name}}' | grep -E '^[0-9a-f]{64}$' | wc -l
You will see:
1
What it means: An anonymous volume with a long random name.
Example 15: Laptop stays clean
Type this:
ls node_modules 2>&1 | head -1
You will see:
ls: cannot access 'node_modules': No such file or directory
What it means: Linux modules live only in the volume.
Step 6: When dependencies change
Why: The old volume is stale.
What: After adding a package, the anonymous volume still has old modules. Recreate it with -V (renew anon volumes).
Example 16: Add package
Type this:
printf '{"dependencies":{"express":"4.21.1","zod":"3.23.8"}}\n' > package.json && docker compose build api | tail -1
You will see:
naming to docker.io/library/shop-api done
What it means: New image has zod.
Example 17: Still missing
Type this:
docker compose up -d && docker compose exec api ls node_modules | grep -c zod
You will see:
0
What it means: Old volume wins.
Example 18: Renew
Type this:
docker compose up -d -V && docker compose exec api ls node_modules | grep -c zod
You will see:
1
What it means: -V makes a fresh anonymous volume from the new image.
Step 7: Named volume version
Why: Same trick, easier to manage.
What: Use a named volume instead, so you can see and delete it by name.
Example 19: Named
Type this:
printf 'services:\n api:\n build: {context: ., target: dev}\n volumes:\n - .:/app\n - api_modules:/app/node_modules\nvolumes:\n api_modules: {}\n' > compose.yaml && docker compose up -d && docker volume ls | grep modules
You will see:
local shop_api_modules
What it means: Readable name.
Example 20: Reset
Type this:
docker compose down && docker volume rm shop_api_modules
You will see:
shop_api_modules
What it means: Delete to refresh dependencies.
Example 21: Size
Type this:
docker system df -v | grep api_modules
You will see:
shop_api_modules 1 58.2MB
What it means: See how big it is.
4. Compose Watch instead
Step 8: Watch with sync
Why: No bind mount at all.
What: Compose Watch copies changed files into the container. Because nothing is mounted, node_modules is never hidden.
Example 22: Rules
Type this:
printf 'services:\n api:\n build: {context: ., target: dev}\n ports: ["3000:3000"]\n develop:\n watch:\n - action: sync\n path: ./src\n target: /app/src\n - action: rebuild\n path: package.json\n' > compose.yaml && docker compose config -q && echo valid
You will see:
valid
What it means: No volumes key.
Example 23: Start
Type this:
docker compose watch
You will see:
✔ Container shop-api-1 Started
Watch enabled
What it means: Builds and watches.
Example 24: Save
Type this:
printf 'console.log("watched")\n' >> src/server.js
You will see:
Syncing service "api" after 1 changes were detected
api-1 | [nodemon] restarting due to changes...
What it means: Sync plus nodemon.
Step 9: Watch with sync+restart
Why: No nodemon needed.
What: If you do not want nodemon, sync+restart restarts the container after copying.
Example 25: Rule
Type this:
printf ' - action: sync+restart\n path: ./src\n target: /app/src\n' > rule.txt && cat rule.txt
You will see:
- action: sync+restart
path: ./src
target: /app/src
What it means: Copy, then restart.
Example 26: Prod-like CMD
Type this:
printf 'CMD ["node","src/server.js"]\n' && echo 'dev stage can now use plain node'
You will see:
CMD ["node","src/server.js"]
dev stage can now use plain node
What it means: Same command as production.
Example 27: Timing
Type this:
time (printf '//x\n' >> src/server.js; sleep 3)
You will see:
real 0m3.0s
What it means: Restart takes about two seconds.
Step 10: Watch rebuild for dependencies
Why: Fresh modules automatically.
What: When package.json changes, rebuild builds a new image and replaces the container, so no -V is needed.
Example 28: Add package
Type this:
npm install dayjs@1.11.13 --package-lock-only && echo saved
You will see:
saved
What it means: Only the lock file changed on the laptop.
Example 29: Watch output
Type this:
printf 'Rebuilding service(s) ["api"] after changes were detected\n'
You will see:
Rebuilding service(s) ["api"] after changes were detected
What it means: Automatic.
Example 30: Check
Type this:
docker compose exec api node -e 'require("dayjs");console.log("ok")'
You will see:
ok
What it means: Module available.
Step 11: ignore and .dockerignore
Why: Keep junk out of the container.
What: Watch rules accept an ignore list, and .dockerignore keeps laptop files out of the build context. Together they stop node_modules, logs and test output from being copied.
Example 31: dockerignore
Type this:
printf 'node_modules\nnpm-debug.log\ncoverage\n.git\n.env\n' > .dockerignore && wc -l .dockerignore
You will see:
5 .dockerignore
What it means: Smaller, safer build context.
Example 32: Watch ignore
Type this:
printf ' ignore:\n - tests/\n - coverage/\n' >> compose.yaml && docker compose config -q && echo valid
You will see:
valid
What it means: Test files are not synced.
Example 33: Context size
Type this:
docker compose build api 2>&1 | grep 'transferring context'
You will see:
=> [internal] load build context transferring context: 41.3kB
What it means: Kilobytes, not hundreds of megabytes.
Step 12: Which one to choose
Why: Simple decision table.
What: Bind mount plus nodemon is simplest and works everywhere. Watch avoids node_modules problems and is faster on Mac and Windows. Production uses neither.
Example 34: Mac speed
Type this:
printf 'bind mount: file events cross the VM, can be slow\nwatch: copies only changed files\n'
You will see:
bind mount: file events cross the VM, can be slow
watch: copies only changed files
What it means: Watch wins on Docker Desktop.
Example 35: Linux
Type this:
printf 'On Linux, bind mounts are native and fast.\n'
You will see:
On Linux, bind mounts are native and fast.
What it means: Either works well.
Example 36: Production
Type this:
docker compose -f compose.yaml -f compose.prod.yaml config | grep -c develop
You will see:
0
What it means: Keep develop blocks out of prod files.
5. Common mistakes and how Sneha avoids them
Mistake one: mounting . without protecting node_modules. Sneha adds the anonymous volume or uses watch.
Mistake two: running npm install on the Mac and mounting those modules into Linux. Native packages break. She installs only inside the image.
Mistake three: forgetting -V after changing dependencies. The old anonymous volume keeps old modules.
Mistake four: nodemon in the production image. She uses the dev target only in compose.override.yaml.
Mistake five: file events not reaching the container. She turns on polling with --legacy-watch or CHOKIDAR_USEPOLLING.
Check yourself
Q1. Why does mounting . break node_modules?
The bind mount hides the image's /app/node_modules.
Q2. What does the line /app/node_modules under volumes create?
An anonymous volume that keeps the image's modules.
Q3. What does up -V do?
Recreates anonymous volumes from the new image.
Q4. Which watch action handles package.json?
rebuild.
Friends, mount only what you edit, protect node_modules, or let watch sync for you. Did you understand? Let us try it once more. Break your app with a full mount, fix it with the anonymous volume, then switch to Compose Watch.
मित्रांनो, फक्त edit करता तेच mount करा, node_modules वाचवा, किंवा watch ला sync करू द्या. समजलं का? चला, पुन्हा एकदा करून पाहू. पूर्ण mount करून app तोडा, anonymous volume ने दुरुस्त करा, मग Compose Watch वर जा.
दोस्तों, सिर्फ़ जो edit करते हैं वही mount कीजिए, node_modules बचाइए, या watch को sync करने दीजिए. समझ आया? चलिए, एक बार फिर करके देखते हैं। पूरा mount करके app तोड़िए, anonymous volume से ठीक कीजिए, फिर Compose Watch पर जाइए.
Ravindra Bagale's Tip
Add node_modules to .dockerignore. Then laptop modules never enter the build context, and the image always installs fresh Linux modules.
node_modules ला .dockerignore मध्ये टाका. मग laptop चे modules build context मध्ये येत नाहीत, आणि image नेहमी fresh Linux modules install करते.
node_modules को .dockerignore में डालिए. फिर laptop के modules build context में नहीं आते, और image हमेशा fresh Linux modules install करती है.
Lab: try at home
- Add nodemon and a dev target.
- Mount ./src and see live reload.
- Mount . and watch the app crash.
- Fix it with an anonymous volume.
- Add a package and use up -V.
- Replace the mount with Compose Watch rules.
Recap
- nodemon restarts Node on save.
- Mounting . hides the image's node_modules.
- An anonymous volume at /app/node_modules fixes it.
- up -V refreshes anonymous volumes.
- Compose Watch syncs files without mounts.
- Production uses neither nodemon nor watch.
Revision card
Interview card