Ravindra BagaleCourses & study guides मराठी

Chapter 53: Docker Compose Tutorial: Services, depends_on Healthchecks, Env Files, Profiles, Overrides, Scaling and Every Command

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.

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.

Image has /app/node_modulesinstalled for LinuxBind mount ./ to /appyour laptop folder covers /appResultnode_modules is hiddenAnonymous volume /app/node_modulessits on top of the mountApp worksLinux modules visible again
Picture 1: why the mount hides node_modules

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

./src changesaction: sync to /app/srcnodemon in containerrestarts the apppackage.json changesaction: rebuildnode_modulesnever synced, stays from image
Picture 2: watch rules for a Node app

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.

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.

Lab: try at home

  1. Add nodemon and a dev target.
  2. Mount ./src and see live reload.
  3. Mount . and watch the app crash.
  4. Fix it with an anonymous volume.
  5. Add a package and use up -V.
  6. 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.

Ravi Sir's Cards for this lesson

मराठी cards