Fix Cannot find module 'dotenv' in Docker Compose (Node)
Reproduced step by step in Compose: why npm install in the Dockerfile is not enough under a bind mount, and why a new package still goes missing after a rebuild.
An Express app started with docker compose up crashes on its first require, and nodemon waits for a change that will not help:
backend_1 | Error: Cannot find module 'dotenv'
backend_1 | at Function.Module._resolveFilename (module.js:469:15)
backend_1 | at Function.Module._load (module.js:417:25)
backend_1 | at Module.require (module.js:497:17)
backend_1 | at require (internal/module.js:20:19)
backend_1 | at Object.<anonymous> (/usr/src/app/index.js:1:63)TL;DR#
The container can only load packages from a node_modules it can see. In this setup there are two ways to lose it. Either nothing installs the dependencies (the image has no npm install, and the bind mount brings none from the host). Or the image does install them, but the bind mount on the app folder hides that copy. Install in the Dockerfile and add an anonymous volume on /usr/src/app/node_modules. If a package you just added is still missing after docker compose up --build, the old volume is being reused: recreate it with docker compose up --build -V backend, scoped to the app service.
The setup in the question#
The Stack Overflow question (83 votes) uses an Express app with nodemon. Its Dockerfile installs node-gyp and nodemon globally and nothing else:
FROM mhart/alpine-node
RUN mkdir -p /usr/src/app
RUN chmod -R 777 /usr/src/app
WORKDIR /usr/src/app
RUN npm install node-gyp -g
RUN npm install nodemon -g
ENV NODE_ENV development
EXPOSE 3000The Compose service then mounts the source over the working directory:
backend:
build:
context: .
dockerfile: dockerfiles/node/Dockerfile
command: npm start
volumes:
- ./backend/:/usr/src/appThe asker notes that ls -al in the container lists the whole app folder. The files are there, the packages are not. No step in this Dockerfile installs the app's own dependencies, so the only node_modules the container could ever see is one the mount brings in from ./backend on the host.
Why it happens#
require('dotenv') is a bare name, so Node looks for it in node_modules folders. The Node.js modules documentation says it starts at the directory of the current module and adds /node_modules, then moves to the parent directory, and so on up to the root. For /usr/src/app/index.js that means /usr/src/app/node_modules, then /usr/src/node_modules, then /usr/node_modules, then /node_modules. If none holds dotenv, the result is MODULE_NOT_FOUND.
The bind mount decides what is in /usr/src/app. Docker's bind mount documentation is explicit: "If you bind-mount a directory into a non-empty directory on the container, the directory's existing contents are obscured by the bind mount." That covers a node_modules the image built in the same folder. The accepted answer adds COPY package.json and RUN npm install to the Dockerfile. That is the right fix for an image run on its own. With the asker's mount still in place, and no node_modules in ./backend on the host, it changes nothing at runtime.
The usual cure is a second, anonymous volume on the node_modules path. Docker's volumes documentation explains why this works: when an empty volume is mounted over a folder that has files in the image, "these files or directories are propagated (copied) into the volume by default". The volume gets a copy of the image's node_modules, and that copy sits on top of the bind mount at that one path.
The same mechanism causes the second failure. The volume is only filled when it is empty. The docker compose up reference says that when the configuration or image has changed, it recreates the containers "(preserving mounted volumes)". After you add a package and rebuild, the new container gets the old, already-filled volume, and the new package is not in it.
Reproduced, one change at a time#
All of this was reproduced on 11 October 2026 with Docker 29.7.2, Docker Compose v5.5.0 and the node:22-alpine image (Node 22.23.3). package.json lists dotenv, index.js requires it, and ./backend on the host has no node_modules. The command is npm start running node index.js, not nodemon, which does not change how modules resolve.
Step 1, the question's setup (no install in the image, bind mount on the app folder):
backend-1 | Error: Cannot find module 'dotenv'
backend-1 | Require stack:
backend-1 | - /usr/src/app/index.js
backend-1 exited with code 1Step 2, the accepted answer's Dockerfile, mount unchanged. The image now runs npm install, and the container still fails with exactly the same Cannot find module 'dotenv'. Running the same image without the mount shows the packages really are inside it:
$ docker run --rm <image> ls /usr/src/app (same image, no mount)
index.js
node_modules
package-lock.json
package.jsonStep 3, one change: an anonymous volume on /usr/src/app/node_modules. The app starts:
backend-1 | dotenv loaded, app started
backend-1 exited with code 0Step 4, add a package. ms goes into package.json and index.js, then docker compose up --build. The rebuilt image contains ms, the container does not:
backend-1 | Error: Cannot find module 'ms'
$ docker run --rm <rebuilt image> ls /usr/src/app/node_modules (no mount)
dotenv
msFix#
- Install the dependencies in the image: copy
package.json, runnpm install, then copy the source:
FROM node:22-alpine
WORKDIR /usr/src/app
COPY backend/package.json ./
RUN npm install
COPY backend/ ./
ENV NODE_ENV development
EXPOSE 3000
CMD ["npm", "start"]- Keep the bind mount for live code, and add an anonymous volume on
node_modulesso the image's copy is not hidden:
services:
backend:
build:
context: .
dockerfile: dockerfiles/node/Dockerfile
command: npm start
volumes:
- ./backend/:/usr/src/app
- /usr/src/app/node_modules- Each time
package.jsonchanges, rebuild and replace the anonymous volume, for the app service only. Thedocker compose upreference describes the flag as "Recreate anonymous volumes instead of retrieving data from the previous containers":
docker compose up --build -V backendThe long form of -V is --renew-anon-volumes. Name the service. In the reproduction, a second service kept a marker file in an anonymous volume on /data. An unscoped docker compose up --build -V -d recreated that service too, and it came back with a new, empty volume and a new marker. The scoped command left it alone. Both flags are needed: docker compose up -V -d backend without --build failed with Cannot find module 'escape-html', because the volume was refilled from the image that predates the new package. The live dev-environment tutorial builds a complete setup around this same node_modules volume pattern, including file-watcher settings.
Verify the fix#
Starting from the step 4 failure, only -V was added, and the app started with ms:
$ docker compose up --build -V
backend-1 | dotenv and ms loaded, app started 172800000
backend-1 exited with code 0With the second service in the file, the scoped form recreated only the backend, and the other service's marker kept the value it had before the command (02:03:06):
$ docker compose up --build -V -d backend
Container aotd-cannot-find-module-compose-backend-1 Recreate
Container aotd-cannot-find-module-compose-backend-1 Recreated
$ docker compose logs store
store-1 | marker=2026-10-11T02:03:06Zdocker compose exec backend ls node_modules on a running service should list every package in package.json. If one is missing, the volume is older than the image.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
down -v, down, or -V: what each one removes#
The highest-voted answer recommends docker compose down -v before the next up. It works, but it removes more than the stale node_modules. The docker compose down reference lists -v as removing "named volumes declared in the "volumes" section of the Compose file and anonymous volumes attached to containers". A database service with a named data volume loses its data.
Plain docker compose down is gentler, but not free. The same reference says anonymous volumes "are not automatically mounted by a subsequent up" and adds: "For data that needs to persist between updates, use explicit paths as bind mounts or named volumes." That is why down cures this error. The reproduction shows it: a third package (kleur) failed after a plain up --build, as in step 4, then docker compose down without -v followed by docker compose up started the app with all three packages. It is also why any other service in the file that keeps its data in an anonymous volume starts again on an empty one. The question's backend depends on mongo and elasticsearch, whose definitions the question does not show, so check where their data lives before you run either form of down.
Neither down nor -V deletes the volume it stops using. In the reproduction, the old volumes were still on disk afterwards: one holding only dotenv, left by -V, one holding dotenv and ms, left by down, and the second service's first volume with its original marker. Even down -v only removes the volumes still attached to containers, so these orphans had to be removed by ID. For a service that -V reset by mistake, the old data is still in that orphaned volume until you remove it.
When the error changes#
If the image switches to USER node and the app now fails with EACCES when it creates node_modules/.cache, the dependencies were installed as root before the switch: run the install after USER. If the error survives the rebuild, an old root-owned node_modules volume is still mounted. The same bind-mount mechanism hides a React app's public/ folder in the index.html variant of this problem. Running npm install on the host to fill ./backend/node_modules also makes the error go away. However, native packages built for macOS or Windows then fail inside the Linux container, as in the node-sass binding error. For a production image built in stages, a missing build tool is a different error, covered in tsc not found in a Docker build. And once the app starts but cannot reach its database service, ECONNREFUSED 127.0.0.1:5432 in Docker Compose is the next fix.
The rule behind all of these: inside a Compose service, /usr/src/app/node_modules holds whatever the last mount put there, not what the last build installed. Install in the image, give node_modules its own volume, and renew that volume whenever package.json changes.
Further reading: Fix Cannot find module 'caniuse-lite/dist/unpacker/agents'.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix Sequelize "Cannot find /config/config.json" in Docker
Reproduced in Docker: why moving WORKDIR alone still fails, why build-time migrations cannot reach the database, and a Compose setup that migrates on start.
Fix Nginx 502 in Docker Compose: upstream localhost
Trace the upstream address in the Nginx log, prove the Node listener is reachable from the proxy, and separate a wrong host from a loopback-only bind.
Fix Docker Compose environment 'unexpected type map' error
See the three wordings this error has had across Compose releases, fix the line in either valid form, and avoid the quoted look-alike that hides the error but loses the variable.


