DevOps

Fix: sh: tsc: not found in Docker build

Two different causes produce the identical "tsc: not found" — here is how to tell them apart, and the multi-stage fix for both.

Mahdi Ben RhoumaMahdi Ben Rhouma
6 min read

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:

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

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

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:

bash
docker build --no-cache -t myapp .

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:

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

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.

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.

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.

Further reading: Fix Firebase Functions Deploy: Missing: X from lock file. Also see Jenkins "Invalid agent type docker specified" Fix.

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