Building for Production

nest build compiles TypeScript to dist/ with tsc, swc or rspack; the image needs dist, package.json and production dependencies only.

Syntaxnest build [apps...] [-b|--builder tsc|swc|rspack|webpack] [-p|--path <tsconfig>] [--type-check] [--all]

nest build turns your TypeScript into JavaScript in dist/. That is all it does: run a compiler, copy a few assets, stop. Nothing Nest-specific happens here. Decorator metadata is emitted into the output and read later, when the process starts.

What actually runs

The default builder is tsc. The config file is tsconfig.build.json when that file exists, and tsconfig.json otherwise. The build config sets rootDir to ./src and excludes test and spec files, which is why the entry point lands at dist/main.js and your tests do not ship.

Swap compilers with -b swc, which is shorthand for --builder swc and the same flag. SWC does no type checking, so pair it with --type-check, which runs tsc in noEmit mode beside it. In NestJS 12 rspack is the default bundler for monorepos, and --webpack is deprecated in favour of --builder rspack.

What belongs in the image

Three things: dist/, package.json, and dependencies installed with npm ci --omit=dev. Not src/, not TypeScript, not .env files. NestJS 12 ships as ESM and needs Node 20.19+ or 22.12+ to run, so the base image has to be recent.

What trips people up

Files that are not TypeScript are not copied. GraphQL schemas, Handlebars templates and .proto files have to be listed under compilerOptions.assets in nest-cli.json, or the build succeeds and the app fails the first time it needs one.

Example

Example · typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
// The .js suffix is the compiled path. A NestJS 12 project with
// "type": "module" resolves relative imports the way Node does at runtime,
// so the extension is written out even though the source file is .ts.
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    // Keep log lines written during bootstrap in memory until the logger is
    // settled, so nothing emitted before listen() is lost.
    bufferLogs: true,
  });

  // Shutdown listeners are off by default. Without this call a SIGTERM from
  // Docker or Kubernetes ends the process with no teardown at all.
  app.enableShutdownHooks();

  // The platform picks the port. Never bake 3000 into a built image.
  await app.listen(process.env.PORT ?? 3000);
}

// Top-level await is available because the emitted module is ESM.
// The CommonJS form of the same line is `void bootstrap();`.
await bootstrap();

When to use it

  • Cut a 1.2 GB image down by shipping dist/ and production dependencies instead of the whole workspace.
  • Speed up a slow CI build by switching the builder to swc while keeping type checking as a separate step.
  • Work out why GraphQL schema files resolve in `nest start` but not from `node dist/main.js`.
  • Pin the Node version in a Dockerfile so an ESM NestJS 12 build does not land on Node 18.

More examples

nest-cli.json for an SWC build with assets

The two asset entries are the shorthand and the longhand of the same thing: a bare string is identical to an object with only "include" set. Both globs are resolved relative to sourceRoot, not the project root. "typeCheck": true is the config-file equivalent of the --type-check flag and is what stops SWC from shipping code that does not compile. "deleteOutDir": true clears dist/ first, which matters when you rename a file and the old output would otherwise linger.

Example · json
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "builder": "swc",
    "typeCheck": true,
    "deleteOutDir": true,
    "assets": [
      "**/*.graphql",
      { "include": "mail/templates/**/*.hbs", "outDir": "dist" }
    ]
  }
}

The package.json an image actually needs

"type": "module" is the line that makes the compiled output runnable, because NestJS 12 packages are ESM. The split between dependencies and devDependencies is exactly what npm ci --omit=dev acts on: the CLI and the compiler are build-time only and must not be in the runtime image. Reach for this when an image is unexpectedly large, or when the container starts and reports that it cannot use an import statement outside a module.

Example · json
{
  "name": "api",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "nest build",
    "start:prod": "node dist/main"
  },
  "dependencies": {
    "@nestjs/common": "^12.1.2",
    "@nestjs/core": "^12.1.2",
    "@nestjs/platform-express": "^12.1.2",
    "reflect-metadata": "^0.2.2",
    "rxjs": "^7.8.1"
  },
  "devDependencies": {
    "@nestjs/cli": "^12.0.8",
    "typescript": "^6.0.2"
  },
  "engines": {
    "node": ">=20.19"
  }
}

Build, then prune

This is the sequence a Dockerfile or CI job encodes. Running it by hand once is the fastest way to find out that an asset is missing from nest-cli.json or that a package you import at runtime is sitting in devDependencies. NODE_ENV=production is not read by Nest itself, but Express and many libraries change behaviour on it, so test with it set.

Example · bash
# Install everything, including the compiler.
npm ci

# nest build reads tsconfig.build.json and writes dist/main.js.
npm run build

# Reinstall from the lockfile without devDependencies. `npm prune --omit=dev`
# edits the tree you already have; `npm ci --omit=dev` discards and rebuilds it.
# Prefer ci, because it cannot leave a half-pruned tree behind.
npm ci --omit=dev

# Start the way the image will.
NODE_ENV=production node dist/main.js

Discussion

  • Be the first to comment on this lesson.