Skip to content

Level 12 — Building the Application ​

Turning source code into something a server can run. This chapter covers the Nuxt 4 frontend, the NestJS backend, and the Prisma steps that sit between them.

The repository layout ​

This guide assumes a monorepo, which is the common shape for a Nuxt + NestJS pair:

myapp/
├── package.json            # workspace root
├── pnpm-workspace.yaml
├── pnpm-lock.yaml          # ONE lockfile for the whole workspace
├── .nvmrc
├── .env                    # production secrets (mode 600, never committed)
├── .env.example
├── ecosystem.config.cjs    # PM2 configuration
├── frontend/               # Nuxt 4
│   ├── package.json
│   ├── nuxt.config.ts
│   └── app/
└── backend/                # NestJS
    ├── package.json
    ├── nest-cli.json
    ├── prisma/
    │   ├── schema.prisma
    │   └── migrations/
    └── src/
yaml
# pnpm-workspace.yaml
packages:
  - 'frontend'
  - 'backend'
json
// package.json (root)
{
  "name": "myapp",
  "private": true,
  "packageManager": "pnpm@9.12.0",
  "engines": { "node": ">=22.0.0 <23.0.0" },
  "scripts": {
    "dev": "pnpm --parallel --filter './*' dev",
    "build": "pnpm --filter backend build && pnpm --filter frontend build",
    "lint": "pnpm -r lint",
    "typecheck": "pnpm -r typecheck",
    "migrate:deploy": "pnpm --filter backend prisma migrate deploy",
    "prisma:generate": "pnpm --filter backend prisma generate"
  }
}

BUILD THE BACKEND FIRST

If the frontend imports shared types from the backend (a common and good pattern), the backend must be built first or the frontend's typecheck fails on missing declaration files. The && above enforces the order.

Development vs production ​

DevelopmentProduction
Commandpnpm devpnpm build then pnpm start
SourceRead live from diskCompiled bundle
RebuildsOn every file change (HMR)Never — build once, run
Source mapsFull, inlineExternal or off
MinificationNoYes
Error outputFull stack traces to the browserGeneric message to the user, detail to logs
NODE_ENVdevelopmentproduction
StartupSeconds (compiles on demand)Milliseconds
MemoryHigh (compiler in memory)Lower
Dependenciesdependencies + devDependenciesdependencies only

NEVER RUN pnpm dev IN PRODUCTION

It is tempting when a build fails. The consequences:

  • Vite's dev server exposes source code and the module graph
  • Detailed error pages leak file paths, dependency versions, and sometimes environment values
  • The dev server is not designed for concurrency and will fall over under real traffic
  • HMR keeps a WebSocket open per client
  • 5–10× the memory usage
  • No minification, so every asset is enormous

If the build fails, fix the build. Running dev mode is not a workaround; it is a security incident waiting to be discovered.

Installing dependencies ​

bash
# SERVER
cd /home/deploy/apps/myapp
pnpm install --frozen-lockfile

--frozen-lockfile fails if pnpm-lock.yaml does not match package.json. That is exactly what you want in production: it guarantees the dependency tree you tested is the one you deploy, and it turns "someone forgot to commit the lockfile" into a clear error instead of a silent version drift.

A BARE pnpm install IN PRODUCTION IS A SUPPLY-CHAIN RISK

Without the flag, pnpm may resolve newer versions matching your semver ranges. A compromised patch release of a transitive dependency lands straight in production, and you are running code that was never reviewed or tested. (Level 23)

--ignore-scripts BREAKS PRISMA

Some hardened CI setups use --ignore-scripts to block malicious postinstall hooks. Reasonable, but it prevents @prisma/client from generating. If you use it, run pnpm prisma generate explicitly afterwards — which you should be doing anyway.

Prisma steps ​

Two commands, both mandatory, in this order.

prisma generate ​

bash
# SERVER
pnpm --filter backend prisma generate

Reads schema.prisma and generates the typed client into node_modules/.prisma/client. Without it:

@prisma/client did not initialize yet. Please run "prisma generate"

DO NOT RELY ON THE POSTINSTALL HOOK

@prisma/client has a postinstall script that runs generate. It does not always fire — pnpm's strict linking, --ignore-scripts, a cached node_modules, or a CI cache restore can all skip it. Make prisma generate an explicit step in your deploy script. It is idempotent and takes a few seconds.

prisma migrate deploy ​

bash
# SERVER
pnpm --filter backend prisma migrate deploy

Applies pending migrations from prisma/migrations/ in order, records them in the _prisma_migrations table, and exits. It never prompts and never resets.

ONLY migrate deploy BELONGS IN PRODUCTION

  • prisma migrate dev — compares the database against the migration history and, on drift, offers to reset (drop everything). In a non-interactive shell it may take that path unattended.
  • prisma db push — pushes the schema directly with no migration record, and will drop columns (with their data) to match.

Both destroy production data. migrate deploy is the only safe command.

RUN MIGRATIONS BEFORE RESTARTING THE APP — USUALLY

Order matters and depends on the change:

Additive change (new table, new nullable column): migrate first, then deploy code. Old code ignores the new column; new code finds it ready.

Destructive change (drop column, rename): use expand/contract across two deploys (Level 9). Never drop a column in the same deploy that stops using it — during a rolling reload, old instances are still selecting it.

Never run migrations concurrently from multiple processes. In PM2 cluster mode, run them from the deploy script before pm2 reload, never from the app's bootstrap code — four instances racing to apply the same migration produces lock contention and partial application.

Checking migration state:

bash
# SERVER
pnpm --filter backend prisma migrate status

Building NestJS ​

bash
# SERVER
cd backend
pnpm build         # nest build → tsc → dist/

Output:

backend/
└── dist/
    ├── main.js
    ├── main.js.map
    ├── app.module.js
    └── ...
json
// backend/package.json
{
  "scripts": {
    "build": "nest build",
    "start": "node dist/main.js",
    "start:prod": "node dist/main.js",
    "typecheck": "tsc --noEmit",
    "lint": "eslint \"{src,test}/**/*.ts\""
  }
}
json
// backend/tsconfig.json — production-relevant settings
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "ES2023",
    "outDir": "./dist",
    "sourceMap": true,
    "declaration": true,
    "strict": true,
    "strictNullChecks": true,
    "noImplicitAny": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "skipLibCheck": true
  },
  "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

nest build DOES NOT TYPECHECK STRICTLY BY DEFAULT

With the SWC builder or certain configurations, type errors can pass through to a successful build and only surface at runtime. Run typecheck as a separate CI step:

bash
pnpm typecheck    # tsc --noEmit

A build that succeeds but produces broken code is worse than one that fails.

Production bootstrap ​

ts
// backend/src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { env } from './config/env';    // Zod validation — Level 8

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    logger: env.NODE_ENV === 'production'
      ? ['error', 'warn', 'log']
      : ['error', 'warn', 'log', 'debug', 'verbose'],
  });

  app.use(helmet());

  app.enableCors({
    origin: env.CORS_ORIGIN.split(','),
    credentials: true,
  });

  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,              // strip properties not in the DTO
    forbidNonWhitelisted: true,   // reject requests containing them
    transform: true,
  }));

  app.setGlobalPrefix('api');

  // Nginx is in front — trust its X-Forwarded-* headers
  app.getHttpAdapter().getInstance().set('trust proxy', 1);

  // Finish in-flight requests on SIGTERM
  app.enableShutdownHooks();

  // 127.0.0.1 only — Nginx is the sole public entrypoint
  await app.listen(env.PORT, '127.0.0.1');

  Logger.log(`API listening on 127.0.0.1:${env.PORT}`, 'Bootstrap');
}

bootstrap();

app.listen(3001) BINDS TO 0.0.0.0

The one-argument form listens on all interfaces. If your firewall ever has a gap, or Docker publishes the port, your API is directly on the internet — bypassing Nginx, so no TLS, no rate limiting, no security headers, no access logs.

Always pass the host: app.listen(env.PORT, '127.0.0.1').

Verify after deploying:

bash
sudo ss -tulpn | grep 3001    # must show 127.0.0.1:3001

trust proxy IS REQUIRED BEHIND NGINX

Without it, req.ip is always 127.0.0.1 (Nginx's address), which breaks IP-based rate limiting, audit logging, and geolocation. Set it to 1 — trust exactly one proxy hop.

Do not set trust proxy: true (trust everything) unless you are certain no client-supplied X-Forwarded-For can reach you, or an attacker can spoof any IP they like.

Graceful shutdown ​

ts
// backend/src/app.module.ts
import { Injectable, OnApplicationShutdown } from '@nestjs/common';

@Injectable()
export class ShutdownService implements OnApplicationShutdown {
  async onApplicationShutdown(signal?: string) {
    Logger.log(`Shutting down (${signal})`, 'Shutdown');
    await this.prisma.$disconnect();
    await this.redis.quit();
  }
}

GRACEFUL SHUTDOWN IS WHAT MAKES ZERO-DOWNTIME DEPLOYS REAL

pm2 reload sends SIGTERM. Without shutdown handling, the process dies instantly and every in-flight request returns a connection error to a real user.

With enableShutdownHooks(), Nest stops accepting new connections, lets active requests finish, closes database and Redis connections, then exits. PM2 only starts the replacement once the old process is gone.

Test it: send a slow request, run pm2 reload api, and confirm the request completes. (Level 13)

Building Nuxt 4 ​

bash
# SERVER
cd frontend
pnpm build         # nuxt build → .output/

.output — what the build produces ​

frontend/.output/
├── nitro.json
├── public/                # static assets, served directly by Nginx
│   ├── _nuxt/
│   │   ├── entry.a1b2c3.js
│   │   └── entry.d4e5f6.css
│   └── favicon.ico
└── server/                # the Nitro SSR server
    ├── index.mjs          # ← the entrypoint you run
    └── chunks/
PathContentsServed by
.output/public/Hashed JS, CSS, imagesNginx directly — much faster than proxying
.output/server/index.mjsThe Node SSR serverStarted by PM2

THE FILENAME HASHES ARE WHY YOU CAN CACHE FOREVER

entry.a1b2c3.js changes its hash whenever content changes. So /_nuxt/* can be served with Cache-Control: public, max-age=31536000, immutable — a year — with no risk of stale assets. The HTML that references them is never cached. (Level 14)

nuxt build vs nuxt generate ​

nuxt build (SSR)nuxt generate (SSG)
Output.output/server + .output/public.output/public only
Needs a Node processYesNo
Dynamic contentRendered per requestBaked at build time
Deploy targetPM2 + Nginx proxyNginx static files only
Use forApps with auth, user dataMarketing sites, docs, blogs

This guide assumes SSR (nuxt build), since the app has authenticated users.

Nuxt configuration for production ​

ts
// frontend/nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: '2025-01-01',

  nitro: {
    preset: 'node-server',
    compressPublicAssets: { gzip: true, brotli: true },
  },

  runtimeConfig: {
    // Server-only — never sent to the browser
    apiInternalUrl: process.env.NUXT_API_INTERNAL_URL || 'http://127.0.0.1:3001',

    public: {
      // ⚠️ SENT TO EVERY BROWSER — nothing secret here
      apiBase: process.env.NUXT_PUBLIC_API_BASE || 'https://api.example.com',
      siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://app.example.com',
    },
  },

  typescript: { strict: true, typeCheck: false },   // typecheck runs in CI

  sourcemap: { server: true, client: false },
})

runtimeConfig.public IS PUBLISHED TO THE INTERNET

Everything under public is serialised into the HTML of every page. A secret placed there is visible in view-source, permanently, and cached in CDNs.

Verify before every release:

bash
grep -riE "sk_live|secret|password|DATABASE_URL" frontend/.output/public/ | head
curl -s https://app.example.com | grep -o '__NUXT__.\{0,2000\}'

SSR SHOULD CALL THE API OVER LOOPBACK

When Nuxt renders on the server, it can reach the API at http://127.0.0.1:3001 — no DNS, no TLS handshake, no round trip through Nginx. The browser must use the public https://api.example.com.

ts
export const useApiBase = () => {
  const config = useRuntimeConfig();
  return import.meta.server ? config.apiInternalUrl : config.public.apiBase;
};

This typically saves 10–50 ms per SSR request and removes a dependency on external DNS during rendering.

sourcemap.client: false IS A DELIBERATE TRADE

Client source maps let anyone read your original TypeScript. Disabling them protects your source but makes browser error reports unreadable.

The good middle ground: generate them, upload them to your error tracker (Sentry) during the build, then delete them before deploying — so Sentry can symbolicate but the public cannot download them.

Memory during the build ​

NUXT BUILDS ARE MEMORY-HUNGRY

A moderate Nuxt 4 app peaks above 2 GB during nuxt build. On a 2 GB VPS the OOM killer terminates it, and — because the killer targets the largest process — it may take PostgreSQL with it.

Symptoms: the build dies with Killed and no other message, or the terminal disconnects.

Confirm:

bash
sudo dmesg -T | grep -i "killed process"
sudo journalctl -k --since "10 min ago" | grep -i oom

Fixes, best first:

  1. Build in CI, ship the .output directory (Level 18). The server never needs build-time memory.
  2. Add swap (Level 4) — slow, but it completes.
  3. Raise the heap limit: NODE_OPTIONS="--max-old-space-size=3072" pnpm build
  4. Stop other services during the build — genuinely a last resort.

The full production build sequence ​

bash
# SERVER
cd /home/deploy/apps/myapp

# 1. Dependencies — exact versions from the lockfile
pnpm install --frozen-lockfile

# 2. Prisma client
pnpm --filter backend prisma generate

# 3. Database migrations (before the new code starts)
pnpm --filter backend prisma migrate deploy

# 4. Build backend, then frontend
pnpm --filter backend build
pnpm --filter frontend build

# 5. Reload with zero downtime
pm2 reload ecosystem.config.cjs --update-env

# 6. Verify
curl -fsS http://127.0.0.1:3001/api/health
curl -fsS http://127.0.0.1:3000/ -o /dev/null -w "%{http_code}\n"

Trimming devDependencies afterwards ​

bash
pnpm prune --prod

Removes devDependencies from node_modules, cutting disk usage substantially.

PRUNE ONLY AFTER BUILDING, AND KNOW WHAT YOU LOSE

--prod before building removes TypeScript, Nest CLI, and Vite — the build then fails.

After pruning you also cannot rebuild without another pnpm install. If a hotfix needs a rebuild during an incident, that is an extra network-dependent step at the worst time. On a server with adequate disk, skipping the prune is a reasonable choice.

Running the built application ​

bash
# SERVER
node backend/dist/main.js                    # API on 127.0.0.1:3001
node frontend/.output/server/index.mjs       # Nuxt on 127.0.0.1:3000

Nuxt's Nitro server reads HOST and PORT from the environment:

bash
HOST=127.0.0.1 PORT=3000 node frontend/.output/server/index.mjs

NITRO DEFAULTS TO 0.0.0.0

Without HOST=127.0.0.1, the Nuxt server listens on all interfaces. Set it explicitly in your PM2 ecosystem file (Level 13) and verify:

bash
sudo ss -tulpn | grep -E '3000|3001'

Both must show 127.0.0.1.

Neither of these should be started by hand in production — they die with your SSH session. PM2 is next.

Build artifacts and what to keep ​

PathKeep on server?In Git?
node_modules/Yes — needed at runtime❌
backend/dist/Yes❌
frontend/.output/Yes❌
frontend/.nuxt/No — build cache only❌
prisma/migrations/Yes✅ Always commit
pnpm-lock.yamlYes✅ Always commit
.envYes (mode 600)❌ Never

ALWAYS COMMIT prisma/migrations/

Without the migration files in Git, migrate deploy has nothing to apply and your production schema silently diverges from what the code expects. They are source code, not build output.

Verifying the build ​

bash
# SERVER
ls -la backend/dist/main.js
ls -la frontend/.output/server/index.mjs
du -sh frontend/.output backend/dist

# Are the artifacts newer than the source? (did the build actually run?)
find backend/src -newer backend/dist/main.js -name "*.ts" | head

# No secrets in the client bundle
grep -riE "sk_live|JWT_SECRET|DATABASE_URL|password" frontend/.output/public/ | head

# Start manually to check for boot errors, then Ctrl+C
node backend/dist/main.js

THE "DEPLOY SUCCEEDED BUT NOTHING CHANGED" CHECK

bash
find backend/src -newer backend/dist/main.js -name "*.ts"

Any output means source files are newer than the build — the build step did not run, or failed silently, and you are serving stale code. This is one of the most confusing deployment failures and this single command diagnoses it. (Level 26)

Troubleshooting ​

ProblemCauseFix
Killed during buildOOMBuild in CI; add swap; raise --max-old-space-size
@prisma/client did not initializeprisma generate not runAdd it as an explicit step
ERR_PNPM_OUTDATED_LOCKFILELockfile out of sync with package.jsonRun pnpm install locally and commit the lockfile
Cannot find module 'dist/main.js'Build failed but the script continuedset -e in the deploy script; check build output
EADDRINUSE :::3001Old process still runningpm2 delete api; sudo ss -tulpn | grep 3001
Frontend 500s on API callsWrong NUXT_PUBLIC_API_BASE, or CORSCheck the browser network tab; verify CORS_ORIGIN
CORS errors in the browserCORS_ORIGIN does not match the frontend origin exactlyScheme, host, and port must all match
Old code still servedBuild did not run; or PM2 not reloaded; or browser/CDN cachefind src -newer dist/main.js; pm2 reload; hard-refresh
Type errors only in CIDifferent TS version or skipLibCheck differencePin versions; run pnpm typecheck locally
Build succeeds, runtime crashesTypes passed but runtime config is wrongZod env validation (Level 8)

Production Checklist — Level 12 ​

  • [ ] pnpm install --frozen-lockfile in every deploy
  • [ ] prisma generate is an explicit deploy step
  • [ ] prisma migrate deploy — never migrate dev or db push
  • [ ] Migrations run before the app reloads, from the deploy script, not from app bootstrap
  • [ ] prisma/migrations/ and pnpm-lock.yaml are committed
  • [ ] Backend builds before frontend
  • [ ] NODE_ENV=production set and verified
  • [ ] NestJS binds 127.0.0.1 explicitly (app.listen(port, '127.0.0.1'))
  • [ ] Nuxt/Nitro has HOST=127.0.0.1 set
  • [ ] Verified with sudo ss -tulpn | grep -E '3000|3001'
  • [ ] trust proxy set to 1 in NestJS
  • [ ] enableShutdownHooks() called and graceful shutdown tested
  • [ ] Helmet and a global ValidationPipe with whitelist: true configured
  • [ ] Nothing secret in runtimeConfig.public; bundle grepped to confirm
  • [ ] .output/public is served by Nginx, not proxied through Node
  • [ ] Builds do not OOM (verified, or moved to CI)
  • [ ] pnpm dev never runs on the production server
  • [ ] find src -newer dist/main.js returns nothing after a deploy

Next: Level 13 — PM2 →