all posts

How to deploy a NestJS API

Ajay Kumar··9 min read

NestJS deploys fail in a very consistent way. The build goes green, the platform starts your app, and the logs say `Cannot find module '/app/dist/main'` — or worse, nothing at all, and the health check just times out until the deploy is marked failed. The cause is almost always the same, and it is not really a NestJS bug so much as a mismatch between what NestJS's generated package.json means and what a deployment platform assumes.

I'm Ajay, I build PandaStack. This is the guide I wish existed: what the mismatch actually is, the four things you must set, and how to verify each one before you deploy rather than after.

The mismatch, precisely

A fresh `nest new` project gives you a package.json with roughly these scripts:

{
  "scripts": {
    "build": "nest build",
    "start": "nest start",
    "start:dev": "nest start --watch",
    "start:prod": "node dist/main"
  }
}

Now look at what a platform's automatic detection does with that. It sees a package.json with no front-end framework in the dependencies, classifies the repo as a plain Node app, and applies the plain-Node defaults: run the package manager's install, then run the package manager's start script. Two problems follow immediately.

  • There is no build step. Plain-Node detection does not assume one, because most Node servers do not need compiling. NestJS does — it is TypeScript, and dist/ does not exist until `nest build` has run. Hence `Cannot find module dist/main`.
  • `npm start` is not the production entry point. In a Nest project, `start` means `nest start`, which is the development runner. The production script is `start:prod`. A platform running `npm start` is running the wrong one, and it will often appear to work while behaving quite differently under load.
This is not specific to any one host. Every platform that infers a plan from package.json has to guess here, and NestJS is one of the few frameworks where the conventional `start` script means something other than 'run this in production'. Set the commands explicitly and the problem disappears everywhere at once.

The four things to set

1. An explicit build command

`npm run build`. That is it. It runs `nest build`, which invokes the TypeScript compiler with Nest's config and emits dist/. Use your actual package manager — `pnpm run build`, `yarn build`, `bun run build` — because the deploy will use whichever lockfile is in the repo.

2. An explicit start command

`node dist/main`. Call node directly rather than going through `npm run start:prod`. Two reasons, and both bite in production: npm adds a process in the middle that can swallow signals, so your container or VM does not shut down cleanly on SIGTERM; and a direct node invocation makes your app PID 1's child in the way process supervisors expect.

3. Bind the platform's port, on 0.0.0.0

This is the second most common failure and it produces a green build with a dead URL. The default `main.ts` from `nest new` listens on a hardcoded port, on localhost. Health checks come from outside the machine and will never see it.

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Graceful shutdown: without this, SIGTERM kills in-flight requests.
  app.enableShutdownHooks();

  // 0.0.0.0 is required — 127.0.0.1 is invisible to an external health check.
  await app.listen(Number(process.env.PORT ?? 3000), "0.0.0.0");
}
bootstrap();

4. Keep the Nest CLI available at build time

`@nestjs/cli` lives in devDependencies, which is correct. But if your install command prunes dev dependencies — `npm ci --omit=dev` is the usual culprit — then `nest build` is not installed when the build step runs. Install everything, build, and prune afterwards if you care about image size. On a platform that runs your build inside the same VM as your app, pruning usually is not worth the extra failure mode.

Putting it together

On PandaStack you can commit these as a `pandastack.json` in the repo root, so the configuration travels with the code instead of living in a dashboard:

{
  "install_command": "npm ci",
  "build_command": "npm run build",
  "start_command": "node dist/main",
  "port": 3000
}

There is no Dockerfile here and none is needed — the base image ships Node 24 pre-warmed via mise, and a `.nvmrc` or an `engines` field pins a different version if you need one. The same three commands are what you would put in Render's or Railway's settings; only the file they live in changes.

Verify before you deploy

Every one of these failures is reproducible locally in about a minute, and doing so is much cheaper than a deploy cycle:

# 1. Does the build actually produce the entry point?
rm -rf dist && npm run build && ls dist/main.js

# 2. Does the production entry point run on its own?
PORT=4000 node dist/main

# 3. Is it reachable from outside localhost?
#    (from another terminal — 0.0.0.0 binding is what makes this work)
curl -sf http://127.0.0.1:4000/healthz && echo OK

If all three pass locally, a deploy failure is about the environment rather than the build, and that is a much shorter list to check.

The database step people skip

NestJS APIs almost always have a database, and the two mistakes are predictable. First: run migrations as a separate step, not on application boot. If your app runs migrations in `bootstrap()`, then scaling to two instances means two processes racing the same migration.

Second: TypeORM's `synchronize: true` is in a lot of tutorials and must never reach production — it alters your schema to match your entities, which eventually means dropping a column somebody's data is in.

TypeOrmModule.forRoot({
  type: "postgres",
  url: process.env.DATABASE_URL,
  autoLoadEntities: true,
  synchronize: false, // never true outside local development
  ssl: { rejectUnauthorized: true },
});

Give it a health endpoint

Platforms decide whether a deploy succeeded by probing an HTTP endpoint, so give them a deliberate one rather than letting them hit `/` and hope. Keep it cheap — a health check that queries the database turns a slow query into a failed deploy and, worse, into a restart loop under load.

@Controller("healthz")
export class HealthController {
  @Get()
  check() {
    return { status: "ok" };
  }
}

That is the whole job. NestJS is not hard to deploy — it just has one convention that automatic detection cannot see through, and once you set the build and start commands explicitly it behaves like any other Node server.

Frequently asked questions

Why does my NestJS app fail with 'Cannot find module dist/main'?

Because the build step never ran. NestJS is TypeScript and dist/ only exists after `nest build`, but automatic framework detection classifies a Nest repo as a plain Node app, and plain Node apps are assumed not to need compiling. Set an explicit build command of `npm run build` and the error goes away.

Should I use npm run start:prod or node dist/main?

Call node directly. Going through npm inserts an extra process between the supervisor and your app, which can swallow SIGTERM and prevent a clean shutdown — in-flight requests get killed instead of drained. `node dist/main` is the same thing without the middleman.

Why does my deploy succeed but the URL not respond?

Almost always the bind address. The default main.ts from `nest new` listens on a hardcoded port on localhost, and health checks come from outside the machine, so they never see it. Listen on 0.0.0.0 and on the port the platform provides in $PORT. That single change fixes the large majority of green-build-dead-URL cases.

Do I need a Dockerfile to deploy NestJS?

No, on any platform with Node detection or buildpacks — Render, Railway and PandaStack will all deploy a Nest repo from package.json alone, provided you set the build and start commands explicitly. A Dockerfile becomes worthwhile when you need a system package the base image lacks, or when you want the exact same image running locally and in CI.

Where should database migrations run?

As their own deploy step, before the new version starts taking traffic — never inside application bootstrap. If migrations run on boot, scaling to two instances means two processes racing the same schema change, and a failed migration turns into a restart loop rather than a failed deploy you can see and roll back.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.