DEV Community

Cover image for Fix: sh: tsc: not found in Docker build
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Fix: sh: tsc: not found in Docker build

TL;DR

sh: 1: tsc: not found (exit code 127) inside a Docker build means the
tsc binary isn't in node_modules/.bin in that stage — either because
typescript (a devDependency) was skipped by a production-only install, or,
less obviously, because it was installed but a two-step install corrupted
package-lock.json badly enough that npm never linked its binary. Give the
stage that compiles TypeScript full devDependencies in a single npm ci
call, then copy just the compiled dist/ output into your slim runtime
stage.

The error

A Dockerfile that compiles TypeScript before starting the app fails partway
through the build:

sh: 1: tsc: not found
The command '/bin/sh -c npm run tsc' returned a non-zero code: 127
Enter fullscreen mode Exit fullscreen mode

This exact failure is a common Stack Overflow question
— the same npm run tsc script that works fine on a developer's machine
fails only inside the container, where node_modules/.bin/tsc simply isn't
there to run. In the original report, the Dockerfile did try to install dev
dependencies before compiling — a dev stage explicitly runs
npm install --only=development — so this isn't always as simple as "dev
dependencies were never installed." Two different, independent causes
produce the identical symptom, and telling them apart determines which fix
actually applies.

Why it happens

typescript is almost always declared under devDependencies, since it's a
build-time tool, not something the running app imports. There are two
distinct, independent ways it can end up missing.

Cause 1: dev dependencies genuinely never installed

The most common version: a Dockerfile sets ENV NODE_ENV=production before
npm install, or passes an older --only=production-style flag (or its
modern replacement) — and every dev dependency, typescript included, is
skipped outright. One answer on the original Stack Overflow thread names
this directly: "It's probably a NODE_ENV environment variable problem...
If you set this way, the dependencies in devDependencies will not be
installed." If that ENV line appears anywhere earlier in the Dockerfile —
even in a base stage a later stage builds FROM — every plain npm install
after it inherits the same behaviour, and node_modules/.bin/tsc — the
symlink npm creates for packages that expose a binary — is simply never
written, because the package that owns it was never fetched at all.

Cause 2: dev dependencies installed, but their binaries never linked

The accepted answer on the same question describes a subtler failure that
happened even though typescript was being installed: the Dockerfile
installs production dependencies first, in one npm install call, and dev
dependencies afterwards, in a second, separate npm install --only=development
call against the same package-lock.json. If the local machine that
generated the lockfile used an older npm than the one running inside the
image, that first production-only install can silently upgrade the
lockfile's format for the production section while leaving the dev section
in the old format. npm then reads a lockfile that claims to be one version
but is only half-migrated, and fails to generate the bin symlink for the
newly installed dev packages — typescript shows up under
node_modules/typescript, yet node_modules/.bin/tsc still doesn't exist.
Running npm ci instead of two separate npm install calls avoids this
class of corruption entirely, since npm ci always installs the exact,
single-version tree recorded in the lockfile in one pass rather than
mutating it across two calls.

Fix

1. Give the build stage devDependencies, then discard them

The standard pattern is a multi-stage Dockerfile: one stage installs
everything and compiles, a second, smaller stage copies out only what's
needed to run.

```dockerfile title="Dockerfile"

---- build stage: has devDependencies, compiles TypeScript ----

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

---- runtime stage: production deps only, no compiler ----

FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/app.js"]




The build stage never sets `NODE_ENV=production`, so `npm ci` installs
`typescript` and `tsc` runs normally. The runtime stage installs with dev
dependencies stripped out and never calls `tsc` at all — it just runs the
already-compiled JavaScript that was copied over.

### 2. If you can't restructure the Dockerfile yet, force dev dependencies back in

As a smaller, temporary fix on a single-stage Dockerfile, override the
production-only install for just the step that compiles, with
`--include=dev`. A plain `npm install -D typescript` isn't reliable here —
if `NODE_ENV` is set to `production` anywhere earlier in the stage, npm's
`omit` setting still defaults to skipping dev dependencies regardless of
which package you name, whereas `--include=dev` explicitly overrides that
default and reinstalls the full tree, dev dependencies included:



```dockerfile
RUN npm ci --include=dev
RUN npx tsc
RUN npm ci --omit=dev
Enter fullscreen mode Exit fullscreen mode

This unblocks the build without a rewrite, but it means the compiler and its
transitive dependencies stay in your production node_modules unless you
clean them up afterwards — the multi-stage approach above avoids that
trade-off entirely, keeps the final image smaller, and is worth migrating to
once the immediate build failure is unblocked.

3. Use npm ci, not npm install, in every stage

Regardless of the devDependencies issue, prefer npm ci over npm install
in Docker. npm ci requires a package-lock.json, deletes any existing
node_modules first, and installs the exact versions the lockfile records —
which avoids the lockfile-format mismatch that can also leave binaries like
tsc unlinked even when the package itself is present.

Verify the fix

Rebuild without cache to rule out a stale layer:

docker build --no-cache -t myapp .
Enter fullscreen mode Exit fullscreen mode

The build stage's npm run build (or npx tsc) step should now print
compiled output instead of sh: 1: tsc: not found. Confirm the final image
is still slim by checking it doesn't carry the compiler:

docker run --rm myapp sh -c "ls node_modules/.bin/tsc 2>&1 || echo 'tsc not in runtime image — correct'"
Enter fullscreen mode Exit fullscreen mode

If you followed the multi-stage pattern, that should print
tsc not in runtime image — correct. Also confirm the compiled output
actually runs, not just that it exists — docker run --rm myapp node
dist/app.js
(or whatever your entry point is) should start the app the same
way CMD does, since a successful tsc compile with an unrelated startup
issue is a separate class of problem from the one this fix addresses.

If the error changes after this fix

Once the compile step succeeds, some projects hit a Node-version mismatch
instead — code that compiles locally on Node 20 throws a different error
inside a container pinned to an older Node base image. That's a distinct
problem from the missing compiler covered here, and the fix is pinning your
FROM node: tag to match your local major version rather than touching
devDependencies again. Two related but distinct failures are worth telling
apart from a missing tsc: if the container instead fails to start the
compiled output because the image's CPU architecture doesn't match the one
it was built for, see
Fix 'exec format error' on AWS Fargate: ARM vs x86 images;
if docker exec complains a command isn't found inside a running
container
(rather than during the build), that's usually the tool you're
trying to run simply not being present in that particular base image, not a
devDependencies problem at all — covered in
Fix OCI runtime exec failed: executable file not found.

A Dockerfile that separates "install everything and build" from "run only
what's needed" avoids this whole class of error — and keeps your final image
smaller, since the TypeScript compiler and its dependencies never ship to
production. If you're setting up the wider development environment around
this Dockerfile — the docker-compose.yml for local Postgres, hot-reload
volumes, and the rest of a day-to-day dev loop — see
Docker Tutorial 2026: Dev Environment with Compose
for the pieces that sit outside the build stage itself.


Originally published at https://www.iloveblogs.blog

Top comments (0)