DevOps

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.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

An Express app started with docker compose up crashes on its first require, and nodemon waits for a change that will not help:

text
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:

dockerfile
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 3000

The Compose service then mounts the source over the working directory:

yaml
backend:
  build:
    context: .
    dockerfile: dockerfiles/node/Dockerfile
  command: npm start
  volumes:
    - ./backend/:/usr/src/app

The 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):

text
backend-1  | Error: Cannot find module 'dotenv'
backend-1  | Require stack:
backend-1  | - /usr/src/app/index.js
backend-1 exited with code 1

Step 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:

text
$ docker run --rm <image> ls /usr/src/app   (same image, no mount)
index.js
node_modules
package-lock.json
package.json

Step 3, one change: an anonymous volume on /usr/src/app/node_modules. The app starts:

text
backend-1  | dotenv loaded, app started
backend-1 exited with code 0

Step 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:

text
backend-1  | Error: Cannot find module 'ms'
$ docker run --rm <rebuilt image> ls /usr/src/app/node_modules   (no mount)
dotenv
ms

Fix#

  1. Install the dependencies in the image: copy package.json, run npm install, then copy the source:
dockerfile
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"]
  1. Keep the bind mount for live code, and add an anonymous volume on node_modules so the image's copy is not hidden:
yaml
services:
  backend:
    build:
      context: .
      dockerfile: dockerfiles/node/Dockerfile
    command: npm start
    volumes:
      - ./backend/:/usr/src/app
      - /usr/src/app/node_modules
  1. Each time package.json changes, rebuild and replace the anonymous volume, for the app service only. The docker compose up reference describes the flag as "Recreate anonymous volumes instead of retrieving data from the previous containers":
bash
docker compose up --build -V backend

The 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:

text
$ docker compose up --build -V
backend-1  | dotenv and ms loaded, app started 172800000
backend-1 exited with code 0

With 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):

text
$ 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:06Z

docker 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

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote