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/# pnpm-workspace.yaml
packages:
- 'frontend'
- 'backend'// 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
| Development | Production | |
|---|---|---|
| Command | pnpm dev | pnpm build then pnpm start |
| Source | Read live from disk | Compiled bundle |
| Rebuilds | On every file change (HMR) | Never — build once, run |
| Source maps | Full, inline | External or off |
| Minification | No | Yes |
| Error output | Full stack traces to the browser | Generic message to the user, detail to logs |
NODE_ENV | development | production |
| Startup | Seconds (compiles on demand) | Milliseconds |
| Memory | High (compiler in memory) | Lower |
| Dependencies | dependencies + devDependencies | dependencies 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
# 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
# SERVER
pnpm --filter backend prisma generateReads 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
# SERVER
pnpm --filter backend prisma migrate deployApplies 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:
# SERVER
pnpm --filter backend prisma migrate statusBuilding NestJS
# SERVER
cd backend
pnpm build # nest build → tsc → dist/Output:
backend/
└── dist/
├── main.js
├── main.js.map
├── app.module.js
└── ...// 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\""
}
}// 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:
pnpm typecheck # tsc --noEmitA build that succeeds but produces broken code is worse than one that fails.
Production bootstrap
// 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:
sudo ss -tulpn | grep 3001 # must show 127.0.0.1:3001trust 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
// 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
# 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/| Path | Contents | Served by |
|---|---|---|
.output/public/ | Hashed JS, CSS, images | Nginx directly — much faster than proxying |
.output/server/index.mjs | The Node SSR server | Started 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 process | Yes | No |
| Dynamic content | Rendered per request | Baked at build time |
| Deploy target | PM2 + Nginx proxy | Nginx static files only |
| Use for | Apps with auth, user data | Marketing sites, docs, blogs |
This guide assumes SSR (nuxt build), since the app has authenticated users.
Nuxt configuration for production
// 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:
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.
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:
sudo dmesg -T | grep -i "killed process"
sudo journalctl -k --since "10 min ago" | grep -i oomFixes, best first:
The full production build sequence
# 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
pnpm prune --prodRemoves 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
# 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:3000Nuxt's Nitro server reads HOST and PORT from the environment:
HOST=127.0.0.1 PORT=3000 node frontend/.output/server/index.mjsNITRO 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:
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
| Path | Keep 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.yaml | Yes | ✅ Always commit |
.env | Yes (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
# 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.jsTHE "DEPLOY SUCCEEDED BUT NOTHING CHANGED" CHECK
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
| Problem | Cause | Fix |
|---|---|---|
Killed during build | OOM | Build in CI; add swap; raise --max-old-space-size |
@prisma/client did not initialize | prisma generate not run | Add it as an explicit step |
ERR_PNPM_OUTDATED_LOCKFILE | Lockfile out of sync with package.json | Run pnpm install locally and commit the lockfile |
Cannot find module 'dist/main.js' | Build failed but the script continued | set -e in the deploy script; check build output |
EADDRINUSE :::3001 | Old process still running | pm2 delete api; sudo ss -tulpn | grep 3001 |
| Frontend 500s on API calls | Wrong NUXT_PUBLIC_API_BASE, or CORS | Check the browser network tab; verify CORS_ORIGIN |
| CORS errors in the browser | CORS_ORIGIN does not match the frontend origin exactly | Scheme, host, and port must all match |
| Old code still served | Build did not run; or PM2 not reloaded; or browser/CDN cache | find src -newer dist/main.js; pm2 reload; hard-refresh |
| Type errors only in CI | Different TS version or skipLibCheck difference | Pin versions; run pnpm typecheck locally |
| Build succeeds, runtime crashes | Types passed but runtime config is wrong | Zod env validation (Level 8) |
Production Checklist — Level 12
- [ ]
pnpm install --frozen-lockfilein every deploy - [ ]
prisma generateis an explicit deploy step - [ ]
prisma migrate deploy— nevermigrate devordb push - [ ] Migrations run before the app reloads, from the deploy script, not from app bootstrap
- [ ]
prisma/migrations/andpnpm-lock.yamlare committed - [ ] Backend builds before frontend
- [ ]
NODE_ENV=productionset and verified - [ ] NestJS binds
127.0.0.1explicitly (app.listen(port, '127.0.0.1')) - [ ] Nuxt/Nitro has
HOST=127.0.0.1set - [ ] Verified with
sudo ss -tulpn | grep -E '3000|3001' - [ ]
trust proxyset to1in NestJS - [ ]
enableShutdownHooks()called and graceful shutdown tested - [ ] Helmet and a global
ValidationPipewithwhitelist: trueconfigured - [ ] Nothing secret in
runtimeConfig.public; bundle grepped to confirm - [ ]
.output/publicis served by Nginx, not proxied through Node - [ ] Builds do not OOM (verified, or moved to CI)
- [ ]
pnpm devnever runs on the production server - [ ]
find src -newer dist/main.jsreturns nothing after a deploy
Next: Level 13 — PM2 →