In a Docker dev container, why does a bind mount over /app hide node_modules, and how do you fix it?
answer
- A mount replaces a subtree, never merges
- The image's install is still there, just covered
- Cover the covering with something deeper
- Empty volumes are seeded from the image path
- Seeded once, so stale after a dependency change
basics
~20 sThe bind mount replaces the whole /app directory, so the node_modules the image installed at build time is shadowed by the host's copy, which is often missing. Fix it with a deeper mount: an anonymous volume at /app/node_modules, seeded from the image.
solid answer
~50 sMounting `$PWD` onto `/app` swaps the entire directory, subtree included, for the host's version. The image's `/app/node_modules`, populated by `npm ci` during the build, is no longer reachable — the container now sees the host's `node_modules`, which is either missing (module-not-found on start) or built on a different OS or architecture, so native addons fail to load. The standard repair is to mount something *deeper* over that one path: `-v "$PWD":/app -v /app/node_modules`. Docker resolves nested mounts longest-path-first, so the anonymous volume wins for that subtree, and because a fresh empty **volume** mounted onto a non-empty image directory is seeded with that directory's content, it comes up holding the dependencies the build installed. The cost is lifecycle: that volume is not refreshed on the next build, so after a dependency change you must drop it as well as rebuild.
code
bash · 2 linesdocker build --target dev -t myapp:dev .
docker run --rm -p 3000:3000 -v "$PWD":/app -v /app/node_modules myapp:devgo deeper
Recognise the symptom: a container that cannot find its dependencies as soon as you mount your project directory into it. Remember that the mount covers the directory, and that the image's install is still there underneath.
Explain the mechanics you will be pushed on: mounts are applied by path depth, the deeper one wins, and an empty volume is seeded from the image's content while a bind mount never is. Be precise about which of the two seeds.
Show that you treat the volume as state. Say when it must be dropped, why a host-built dependency tree is not portable into a Linux container, and why mounting only the directories you edit is a better design than patching the overlap.
Set the convention for the team: dependency directories live outside the mounted tree, dev commands are reproducible from a clean clone, and resetting local state is one documented command rather than folklore passed between engineers.
## The shadowing rule A bind mount replaces a path in the container's mount namespace with a host directory — the whole subtree, not a merge. If the image installed dependencies *inside* the directory you mount over, the container stops seeing them the moment the mount exists. This is the single most common inner-loop failure, and it is not specific to Node: it hits `node_modules`, a Python `.venv`, `vendor/`, Maven `target/`, and `bin/`/`obj/` for a .NET service just as hard. Two distinct symptoms come out of it: * **Nothing there.** The host has never installed dependencies (a fresh clone, or a policy of installing only in the image), so the container starts and dies with a module-not-found error even though the image contains a perfectly good install. * **The wrong thing there.** The host *does* have a `node_modules`, installed on macOS or Windows, or on a different CPU architecture. Pure-JavaScript packages happen to work; anything with a compiled native addon fails to load, because the `.node` binary in it was built for the host's platform, not the image's Linux one. The same applies to a `.venv` with compiled wheels or a `bin/` built by a different SDK. ## Why a deeper mount fixes it Docker sorts a container's mounts by target-path depth and applies them shortest first, so a mount at `/app/node_modules` is layered on top of the one at `/app`. Whatever you put there wins for that subtree, and the host's version is no longer visible inside the container. An **anonymous volume** is the usual choice: ```bash docker run --rm -p 3000:3000 \ -v "$PWD":/app \ -v /app/node_modules \ myapp:dev ``` Giving a container path with no source asks Docker to create a volume with a generated name and mount it there. This works because of a rule that applies to **volumes and not to bind mounts**: when an empty volume is mounted onto a directory that has content in the image, Docker copies that content into the volume the first time it is used. So the volume comes up containing exactly the dependency tree the build produced, on the right platform, and the host's copy is irrelevant. ## The lifecycle trap That seeding happens only while the volume is empty — that is, once. Rebuild the image with a new dependency and re-run: the *old* volume is reattached and still shadows the image's fresh `node_modules`, so the container reports the new package missing and the build cache gets blamed for something it did not do. The volume has to go with the container: * `docker run --rm ...` removes the container's anonymous volumes when it exits, which is why the flag matters in a dev command; * otherwise `docker rm -v <container>`, or `docker volume prune` to clear unused anonymous volumes; * in a local stack file, tearing the project down with the volumes flag removes them. A named volume behaves the same way but survives on purpose, so it makes the staleness worse unless you delete it deliberately. ## The alternatives The anonymous-volume trick is a workaround for a layout problem, and there are cleaner options: * **Mount less.** Bind-mount only the directories you actually edit — `-v "$PWD/src":/app/src` — and the dependency directory is never covered in the first place. This is the tidiest fix and it also shrinks what the watcher has to scan. * **Move dependencies off the mounted path.** Install them somewhere the mount cannot reach and point the runtime at that location with the ecosystem's own search-path variable. Nothing is shadowed because nothing overlaps. * **Install on the host too.** Keep host and container installs in step so the shadow is harmless. This only works when the platform matches and nothing compiles natively — it is fragile, and it drags the host toolchain back into a workflow that containers were supposed to remove. ## What to say in an interview Name the mechanism, not just the flag. The mount is a subtree replacement; the deeper mount wins; volumes (not bind mounts) seed from the image when empty; the seeding is once-only, so the volume is part of the state you must reset when dependencies change. A candidate who says "add `-v /app/node_modules`, it just works" and cannot explain why the volume has content in it will not survive the follow-up about the stale dependency.
- Why does the anonymous volume come up with the dependencies in it rather than empty?Because Docker seeds an empty volume from the image. When a volume with no content is mounted onto a container path that the image has content at, that content is copied into the volume on first use. Bind mounts get no such treatment — they always show the host directory exactly as it is, which is precisely why the bind mount shadowed the dependencies in the first place.
- You added a package, rebuilt the image, and the container still says it is missing. What happened?The anonymous volume from the previous run was reattached. It was seeded once, when it was empty, and it now shadows the image's newer dependency directory, so the fresh install is invisible. Remove the container with its volumes (or run with --rm so they go on exit) and start again. The build cache is not the culprit: changing the manifest invalidates the install layer.
- Would bind-mounting only ./src instead of the project root avoid all of this?Yes, and it is usually the better design. Nothing is mounted over the dependency directory, so there is nothing to shadow and no volume to keep in sync. It also narrows what a file watcher must scan. The cost is that files outside the mounted directories — the manifest, config, migrations — no longer appear live, so touching those means a rebuild.
saying these in an interview costs you the question
- Claims the bind mount deleted the image's dependencies
- Thinks the container and host merge the two directories
- Says a bind mount is also seeded from the image
- Never removes the anonymous volume after a dependency change
- Assumes a host node_modules works in a Linux container
- Blames the build cache for the stale dependency tree