DevOps

Fix Cannot find module '/dist/main' in a Docker build

Find out which file the builder stage really emitted, then fix the Dockerfile order and the TypeScript output layout so node dist/main starts.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

TL;DR#

node dist/main fails because the runtime image has no dist/main.js. In the Dockerfile from the question, RUN npm run build runs before COPY . ., so the builder never sees src/ and dist/ ends up without main.js. Copy the source before building. If the compiler then writes dist/src/main.js (any .ts file outside src/ is compiled and no rootDir is set), add "rootDir": "./src" and "include": ["src"] to tsconfig.build.json. Then add a test -f dist/main.js step so the build fails instead of the container.

The Stack Overflow question shows a NestJS Dockerfile that builds on node:14 and runs on node:14-alpine with CMD ["npm", "run", "start:prod"]. The container exits with:

text
Error: Cannot find module '/dist/main'

Both answers are right, about different faults. One says main.js sits at /dist/src/main.js; the other moved COPY . . above RUN npm run build. The reproduction below shows that a project can hit both, one after the other.

What the reproduction showed#

This was reproduced on 15 October 2026 with Docker and the node:24-alpine image in both stages, using plain tsc -p tsconfig.build.json as the build script instead of nest build, and "start:prod": "node dist/main" as in a Nest project. The test project has src/main.ts, a prisma/seed.ts, and a tsconfig.build.json that extends tsconfig.json with only an exclude list. That is the shape of the Nest starter's file from 2019 until a template update on 27 August 2026 (commit c8fb6bc). The question does not show what is in its prisma/ folder, so the seed file is an assumption of the test, not a fact about the asker's project.

With the question's order (copy package*.json, prisma/ and the tsconfig files, install, build, then COPY . .), the builder compiled the only TypeScript file it had:

text
#15 0.241 dist/seed.js

The runtime stage then failed exactly as in the question, with Node.js v24.22.0 (stack frames after the first trimmed):

text
> distmain-repro@1.0.0 start:prod
> node dist/main
 
node:internal/modules/cjs/loader:1568
  throw err;
  ^
 
Error: Cannot find module '/dist/main'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1564:15)
  code: 'MODULE_NOT_FOUND',
  requireStack: []

If the prisma/ folder holds no .ts file at all, tsc has nothing to compile and the build stops earlier, which is a different symptom:

text
error TS18003: No inputs were found in config file '/app/tsconfig.json'. Specified 'include' paths were '["**/*"]' and 'exclude' paths were '["/app/dist"]'.

Why it happens#

Two separate things decide whether dist/main.js exists.

The build only sees what was copied before it. Each Dockerfile instruction runs against the filesystem built up so far. In the question, COPY . . comes after RUN npm run build, so src/ reaches the builder only once compiling is finished. Copying it afterwards does nothing for dist/. The .dockerignore also lists dist/, so a dist folder built on the host never enters the image either. Whatever the builder compiled is the only dist there is.

TypeScript mirrors the input tree under outDir. The tsconfig reference for rootDir gives the default as the longest common path of all non-declaration input files (the behaviour before TypeScript 6.0). With only src/main.ts, that common path is src/ and the output is dist/main.js. Add prisma/seed.ts and the common path becomes the project folder, so the output moves to dist/src/main.js and dist/prisma/seed.js. TypeScript 6.0 changed the default. The 6.0 release notes say:

In TypeScript 6.0, the default rootDir will always be the directory containing the tsconfig.json file.

The path in the message has its own explanation. The runtime stage in the question has no WORKDIR, and the Dockerfile reference states:

If not specified, the default working directory is /.

npm run start:prod therefore runs node dist/main from /, and Node looks for /dist/main. A missing WORKDIR makes the path look odd, but it does not cause the error. As a negative control, adding WORKDIR /app to the runtime stage alone only changed the message:

text
Error: Cannot find module '/app/dist/main'

Fix the build order, then the output layout#

  1. Copy the source before you build. Install from the package files first so the dependency layer stays cached, then copy everything and compile:
dockerfile
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
  1. Check where the compiler put main.js. In the reproduction, this change alone was not enough. With prisma/seed.ts still compiled and no rootDir, the builder now wrote:
text
#13 0.172 dist/prisma/seed.js
#13 0.172 dist/src/main.js

and the container still failed:

text
Error: Cannot find module '/dist/main'
  1. Pin the build to src/. Put rootDir and include in tsconfig.build.json, the file the build script compiles. This matches the current Nest starter template:
json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "rootDir": "./src"
  },
  "include": ["src"],
  "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

The builder then emitted a single file, and the container started:

text
#13 0.228 dist/main.js
dist main.js started, would listen on port 5273

The trade-off: with include set to src, prisma/seed.ts is no longer compiled by this build. If the image needs to run the seed script, give it its own build or run it with a TypeScript runner outside the production image.

  1. Give the runtime stage its own WORKDIR and copy what start:prod needs. The test project needs only the three paths below; keep copying any assets your app reads at run time, such as the question's protos/ and prisma/ folders:
dockerfile
FROM node:24-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules/
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/dist ./dist/
CMD ["npm", "run", "start:prod"]

Verify the fix#

Make the build fail when the entry point is missing, instead of finding out at docker run. Add this line to the builder stage, right after RUN npm run build:

dockerfile
RUN test -f dist/main.js || (echo "dist/main.js missing; emitted files:" && find dist -name '*.js' && exit 1)

This is the complete Dockerfile that was tested, with the guard in the builder stage:

dockerfile
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
RUN test -f dist/main.js || (echo "dist/main.js missing; emitted files:" && find dist -name '*.js' && exit 1)
 
FROM node:24-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules/
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/dist ./dist/
CMD ["npm", "run", "start:prod"]

With the rootDir fix in place, the image built and docker run printed the app's start line. With the old tsconfig.build.json swapped back in, the same Dockerfile stopped at the guard and listed what was really emitted:

text
#11 0.185 dist/main.js missing; emitted files:
#11 0.186 dist/prisma/seed.js
#11 0.186 dist/src/main.js

That listing is the quickest diagnosis there is. If it shows dist/src/main.js, fix the layout (step 3). If dist holds no main.js anywhere, the source did not reach the builder (step 1). The same rule as for a missing Next.js .next/BUILD_ID applies: the start command serves an artefact, it does not create one, so prove the artefact exists in the stage that runs it.

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.

TypeScript 6 and 7: include without rootDir now fails with TS5011#

The same project behaves differently depending on which compiler npm install pulls in. The reproduction ran three versions against the same files.

With no rootDir and prisma/seed.ts compiled, TypeScript 5.9.3, 6.0.3 and 7.0.2 all exited cleanly and all wrote dist/src/main.js. Nothing warns you, and the container fails at start.

With "include": ["src"] but no rootDir, TypeScript 5.9.3 inferred src/ and wrote dist/main.js. TypeScript 6.0.3 refused the build (BuildKit prefixes each line with its step number and timestamp):

text
#12 1.421 error TS5011: The common source directory of 'tsconfig.build.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout.

TypeScript 7.0.2, the npm latest on 15 October 2026, printed the same TS5011 error with a link to the migration notes and exited with code 2. Inside RUN npm run build, that non-zero exit fails the image build, which is better than a container that dies at start. The release notes do not explain why the compiler stops here rather than applying the new default; what was observed is that it does. Either way, setting rootDir explicitly gives the same dist/main.js on all three versions, which is what the fix in step 3 does.

Variants that end in the same message#

  • The source reached the builder, the dependencies did not reach the runtime. If a Compose file bind-mounts the project over the app folder, it can hide the image's node_modules. That gives a different module name in the error, and is covered in Cannot find module 'dotenv' in Docker Compose.
  • A .dockerignore rule removed an input. The question's file excludes dist/ on purpose. A broader rule can drop a file the build needs, as in Vite's missing index.html entry. The fix there is the same habit: list the files right after COPY . ..
  • The build itself cannot run. If you switch the builder to a production-only install, typescript (a devDependency) is skipped and the build stops with sh: tsc: not found before dist exists.

The next error after it starts#

The question's builder is node:14, a Debian image, and its runtime is node:14-alpine, which uses musl. It copies node_modules from one to the other, and it ships a prisma/ folder. For Prisma Client with a query engine, the Prisma 6 schema reference describes binaryTargets as the setting that specifies the OS on which Prisma Client will run, with native as the default. A client generated in the Debian builder can therefore fail on Alpine even after dist/main.js loads. If the error changes to Prisma's message that the query engine library for the current platform could not be found, continue with the Prisma query engine platform error. Using the same base image in both stages, as the fixed Dockerfile above does, avoids that mismatch from the start.

Cannot find module '/dist/main' is not a Docker path problem: the file npm run start:prod asks for was never produced where it looks. Run the build after the source is copied, pin rootDir so main.ts compiles to dist/main.js, and let a one-line test -f guard catch the next layout change during the build instead of in production.

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