NestJS gives you structure that most Node frameworks don't: modules, dependency injection, a real CLI. That structure pays off in production too — but there are a handful of things the scaffold doesn't set up for you: binding to the right port, turning off schema sync, running migrations at the right moment, and reading a single DATABASE_URL instead of five discrete variables. Here's the full path from nest build to a live URL.
Building NestJS for production
The Nest CLI compiles TypeScript into dist/:
npm run build # runs `nest build`The default scaffold already includes the production start script:
{
"scripts": {
"build": "nest build",
"start:prod": "node dist/main"
}
}So your production lifecycle is exactly two commands: npm run build, then node dist/main.js. No ts-node, no watchers, no @nestjs/cli at runtime — those are dev dependencies and should stay that way.
Port and host: the two-line fix
The scaffolded main.ts listens on a fixed port. In any containerized environment, the platform tells you the port via the PORT environment variable and routes traffic to it — so read it, and bind to all interfaces:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // let SIGTERM drain connections gracefully
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
}
bootstrap();enableShutdownHooks() matters more than it looks: without it, Nest won't run onModuleDestroy/onApplicationShutdown, so a rolling deploy kills in-flight requests and leaves database pools to time out instead of closing cleanly.
Environment variables with @nestjs/config
Install the config module and register it globally:
npm install @nestjs/config// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
// ...your feature modules
],
})
export class AppModule {}Now ConfigService is injectable everywhere, and you read configuration from the environment instead of hardcoding it — which is exactly what a platform-injected DATABASE_URL needs.
Connecting PostgreSQL via DATABASE_URL
Managed platforms hand you one connection string, not discrete host/user/password values. TypeORM accepts it directly through the url option:
npm install @nestjs/typeorm typeorm pg// src/app.module.ts
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
url: config.get<string>('DATABASE_URL'),
autoLoadEntities: true,
synchronize: false, // never true in production
}),
}),
],
})
export class AppModule {}Two decisions in there are non-negotiable:
synchronize: false. Schema sync is great in development and a table-dropping hazard in production. TypeORM will happily "synchronize" a renamed column by dropping the old one — with your data in it. Migrations only.autoLoadEntities: truesaves you from maintaining a manual entity list that silently drifts from reality.
On PandaStack, if you attach a managed PostgreSQL instance (14.x or 16.x) to your app, DATABASE_URL is injected automatically — the factory above picks it up with zero copying of credentials between dashboards.
Migrations with TypeORM 0.3
TypeORM's CLI needs a DataSource definition. Keep it separate from the Nest module so both the app and the CLI can use it:
// src/data-source.ts
import 'dotenv/config';
import { DataSource } from 'typeorm';
export default new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: ['dist/**/*.entity.js'],
migrations: ['dist/migrations/*.js'],
});In development, generate a migration from your entity changes (using the ts-node-aware binary against the TypeScript source):
npx typeorm-ts-node-commonjs migration:generate src/migrations/AddUsers -d src/data-source.tsIn production, run migrations against the compiled output:
npx typeorm migration:run -d dist/data-source.jsThe important operational rule: run migrations as a separate step before the new version takes traffic, not inside the app's bootstrap. If migrations run at boot and you deploy with more than one replica, two instances race to apply the same migration, and the loser crashes on a lock or a duplicate-object error. A release step (or a one-off command against the new image) is the boring, correct answer.
And the corollary from the AdonisJS world applies here too: forward-only in production. migration:revert exists for development mistakes, not as a production rollback strategy — write a new migration instead.
A production Dockerfile
If you'd rather control the image yourself instead of using buildpack detection:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]The two-stage split keeps the Nest CLI, TypeScript, and your dev dependencies out of the final image — the runtime stage carries only production node_modules and the compiled dist/. Note that dist/data-source.js and dist/migrations/ ride along in dist/, so the same image can run migrations.
Deploying on PandaStack
With the app built and the database config reading DATABASE_URL, going live is short:
- 1Provision the database. Create a managed PostgreSQL instance from the dashboard. Backups are scheduled daily and retained per plan (7 days on Free, 15 on Pro, 30 on Premium).
- 2Connect the repo as a container app. PandaStack auto-detects Node and the build/start commands; if you keep a Dockerfile in the repo root, it uses that instead. Either way the build runs in rootless BuildKit inside an ephemeral Kubernetes Job — no Docker socket exposure.
- 3Watch the build logs live. They stream in real time, so a failed
npm cior TypeScript error shows up as it happens, not after a five-minute wait for a status flag. - 4Run migrations via a one-off command (or release step) with
npx typeorm migration:run -d dist/data-source.jsbefore the new version serves traffic. Because the app and database are attached, the command sees the same injectedDATABASE_URL. - 5Push to deploy. Every subsequent
git pushtriggers a build and rollout automatically. If a deploy goes sideways, rollbacks and deployment history are built in.
Environment variables beyond DATABASE_URL — API keys, JWT_SECRET, whatever your ConfigService reads — are set in the app's environment settings and injected at runtime.
One honest note about the free tier: apps scale to zero when idle and run on preemptible nodes, so the first request after a quiet period eats a cold start. That's the right trade for a side project and the wrong one for a latency-sensitive API — paid tiers run on stable nodes and stay warm.
The checklist
Read PORT, bind 0.0.0.0, enable shutdown hooks. synchronize: false, always. One DataSource file shared by app and CLI, compiled into dist/. Migrations as a pre-traffic step, forward-only. Two-stage image with production dependencies only.
That's a NestJS deployment that behaves the same on your laptop, in CI, and in production. If you want to see the whole loop — push, build, migrate, live URL with the database wired in — you can run it on https://pandastack.io.