engineering·7 min read

Cannot find module '/app/dist/server.js': why a clean build still dies on start

The build went green. The image pushed. The container started and stopped, and the platform told you the task exited. The log has one useful line in it and it names a file you have never heard of.

> my-app@1.0.0 start
> node dist/server.js

node:internal/modules/cjs/loader:1210
  throw err;
  ^

Error: Cannot find module '/app/dist/server.js'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1207:15)
    at Module._load (node:internal/modules/cjs/loader:1038:27) {
  code: 'MODULE_NOT_FOUND',
  requireStack: []
}

Read the two lines above the stack trace before the stack trace itself. They tell you the whole story: npm start is running node dist/server.js, and /app/dist/server.js is not in the image.

Nothing is wrong with your code. Your start command points at build output, and in the image nothing produced that output.

Why a green build tells you nothing about this

A Docker build succeeding means the instructions in the Dockerfile ran without error. It does not mean the resulting image can start. If your Dockerfile installs dependencies and copies source, it will build perfectly and produce an image with no dist directory, because nothing asked for one.

Locally you have a dist folder because you ran npm run build weeks ago and forgot. It is almost certainly in your .gitignore, and often in your .dockerignore too, which is correct: build output does not belong in version control. It also means the container is a clean machine that has never once run your build.

This is why “works on my machine” is so convincing here. The difference is not the platform, the Node version or the architecture. It is one directory that exists in one place.

Confirm it in ten seconds

Look inside the image rather than guessing:

docker build -t myapp .
docker run --rm -it --entrypoint sh myapp -c "ls -la && ls -la dist 2>&1 | head"

If dist is missing or empty, you have your answer. Also worth checking, because it is the same class of problem:

node -e "console.log(require('./package.json').scripts)"

If start runs a path under dist, build, .output or out, then something has to create it before the container starts.

Four fixes, in the order I would try them

1.Run the build in the image

The direct fix. Add the build step, and make sure it runs after dependencies are installed and before the start command is used.

COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build      # <- the missing line
CMD ["npm", "start"]

If npm run build fails here with tsc: not found, your build tool is in devDependencies and you installed with npm ci --omit=dev or set NODE_ENV=production before installing. Install everything, build, then prune.

2.Point the start command at what exists

If there is no compile step at all, and dist/server.js is simply the wrong path, say the real one. A plain JavaScript project often has src/server.js and a start script copied from a TypeScript template.

"scripts": { "start": "node src/server.js" }

3.Check .dockerignore is not deleting your build

If you build on your machine and copy the result in, a .dockerignore containing dist silently removes it from the build context. The COPY then succeeds and copies nothing.

Building inside the image is more reliable precisely because it does not depend on the state of your working directory.

4.In a multi-stage build, copy the output across

A build stage that compiles and a runtime stage that does not copy dist gives you this error with a perfectly clean build log, because the compile really did happen, in a stage that was thrown away.

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist   # <- the line people forget
CMD ["npm", "start"]

If you are on ECS, the stop reason is the tell

ECS reports this as Essential container in task exited with exit code 1, and the service then starts another task, which does the same thing. The replacement loop is what you notice first: the target never becomes healthy, the load balancer keeps returning 503, and the deployment eventually rolls back.

The deploy is not the problem, so reading the build log will not help. The answer is in the container’s own stdout, which is one line long and says exactly what happened.

Why we care about this particular error

Eigon deploys apps onto AWS, and this failure is common enough that we treat it as a first-class case. When a deployment fails we read the container’s crash output rather than the build log, so the answer you get is “Cannot find module dist/server.js” and not “exit status 1”.

Then, because the repository already tells us there is a src/server.js and no compile step, we propose the exact edit, apply it if you agree, redeploy, and record whether it worked. A fix has to succeed three times before it gets reused on anyone automatically, which keeps a lucky guess from becoming policy.

Curious whether your repo would hit this? Paste a public repository URL and you get the problems that would stop a deploy, named up front. No account required.