--- title: Lean production — run several Node apps on a 1 GB server summary: Production builds only, a heap cap and a hard memory ceiling per app, builds on the laptop, leak-proof habits, and a memory reading after every deploy. when_to_use: Before putting any Node app on a small shared server, when free memory is shrinking, or when an app still runs through tsx / ts-node / nodemon / a dev server in production. prerequisites: A server with systemd (new-server.md) and an app that can be compiled to plain JavaScript (`npm run build` → `dist/`). related: [workflows/ship-a-site.md, workflows/new-server.md, skills/ship-a-site/references/systemd-units.md, reference/gotchas.md] last_verified: 2026-09-26 --- # Lean production ## Goal Every app on the server: 1. runs **compiled JavaScript with plain `node`** — never `tsx`, `ts-node`, `nodemon`, `vite dev`, `next dev`, or `npm start`/`npm run dev` wrappers that start them; 2. starts with `--max-old-space-size=128` and sits under a systemd `MemoryMax=256M`; 3. is **built on the laptop**, not on the server; 4. is measured right after each deploy and again a day later. Why it matters, with real numbers from one 1 GB server: an API run through `tsx` with no cap held **~325 MB** — a third of the machine. Two APIs of similar size, built and capped, held **40–55 MB** each. After building and capping the first one it dropped to **~64 MB**, and available memory went from ~250 MB to ~575 MB. No bigger server needed. ## Inputs (placeholders used) | Placeholder | Meaning | |---|---| | `` | SSH alias of the server | | `` | systemd unit / app name | | `` | the non-root user the app runs as | | `` | the app's loopback port | ## Steps ### 1. Find out who is holding the memory (server) `free -m` says the box is filling; systemd's cgroups say who. Every service's `MemoryCurrent` counts the process and its children: ```bash for s in $(systemctl list-units --type=service --state=running --no-legend | awk '{print $1}'); do m=$(systemctl show "$s" -p MemoryCurrent --value) [ "$m" != "[not set]" ] && [ "$m" -gt 20000000 ] 2>/dev/null && printf "%-32s %5d MB\n" "$s" $((m/1048576)) done | sort -k2 -n -r free -m ``` Read `available`, not `free`: `buff/cache` is the kernel borrowing idle RAM for disk cache and it gives it back on demand. **Check:** you have a table of services with MB each. Anything much bigger than its peers is the first suspect — look at *how* it runs: ```bash systemctl cat | grep -E 'ExecStart|MemoryMax' ``` ### 2. Make the app build to `dist/` (code) Plain `tsc` works when the app imports only npm packages. If it imports a sibling workspace package that ships TypeScript source, `tsc` output will try to `import` a `.ts` file at runtime and crash — **bundle** instead, keeping npm packages external: ```js // build.mjs import { build } from "esbuild"; await build({ entryPoints: ["src/index.ts"], outfile: "dist/index.js", bundle: true, platform: "node", format: "esm", target: "node22", plugins: [{ name: "external-npm-packages", setup(b) { // bare imports stay external, except your own workspace scope b.onResolve({ filter: /^[^./]/ }, (args) => args.path.startsWith("@/") ? undefined : { external: true }); }, }], }); ``` Keep npm packages external: ORMs like Prisma load a native engine from `node_modules` by path, and several packages read their own files at runtime. > Keep `dist/index.js` at the **same folder depth** as `src/index.ts` if the code > resolves paths relative to `import.meta.url` (e.g. serving `../../web/dist`). One > level deeper and the site 404s while the health check stays green. Scripts: ```json "dev": "tsx watch src/index.ts", "build": "node build.mjs", "start": "node --max-old-space-size=128 dist/index.js" ``` Move `tsx`, TypeScript, Vite, test runners to `devDependencies`. **Check:** `npm run build && node dist/index.js` starts locally and `/api/health` → 200. ### 3. Run it with node directly, capped (server) ```ini # /etc/systemd/system/.service — the lines that matter [Service] WorkingDirectory=/home//apps/ EnvironmentFile=/home//apps//.env Environment=NODE_ENV=production ExecStart=/usr/bin/node --max-old-space-size=128 dist/index.js MemoryMax=256M Restart=always RestartSec=5 ``` | Setting | Enforced by | At the limit | |---|---|---| | `--max-old-space-size=128` | V8, inside the process | GC gets aggressive early; if live data truly exceeds it, the process exits with a heap error | | `MemoryMax=256M` | the kernel (cgroup) | the service is killed; `Restart=always` brings it back in 5 s — *only this app*, instead of the OOM killer choosing any victim | The 128 → 256 gap is room for native memory, buffers and the ORM engine. Put `ExecStart` on `/usr/bin/node`, **not** `npm start`: npm adds a second process, may not forward signals on restart, and hides the flag from systemd. ```bash sudo systemctl daemon-reload && sudo systemctl restart ``` **Check:** ```bash systemctl show -p MemoryCurrent -p MemoryMax -p NRestarts ps -o rss=,args= -C node # one node process per app, no tsx, no npm ``` ### 4. Build on the laptop, install production deps only (deploy script) Compiling is the biggest memory spike a small server ever sees. Do it on the laptop and ship the output: ``` laptop: tests → build api/dist + web/dist └─ rsync api/dist, prisma/, package*.json ─▶ server: npm ci --omit=dev → migrate → restart └─ rsync web/dist ─▶ server staging dir ─▶ sudo rsync --chown=root:root → ``` `npm ci --omit=dev` means no compiler, bundler or test runner ever lands in the server's `node_modules`. Two traps: - **macOS `openrsync` has no `--chown`.** Ship to a staging folder the deploy user owns, then run the server's GNU rsync under sudo to publish as root:644. - **A failed build must abort the deploy.** Otherwise the script restarts the service on the previous `dist/` and reports success while shipping nothing. Template: `skills/ship-a-site/scripts/deploy.sh.template`. **Check:** `ssh 'ls ~/apps//node_modules/.bin | grep -cE "^(tsc|tsx|vite)$"'` → `0`. ### 5. Measure after every deploy, and a day later (server) ```bash systemctl show -p MemoryCurrent -p MemoryPeak -p NRestarts ``` | Field | Meaning | |---|---| | `MemoryCurrent` | what it holds now (includes page cache charged to it, so it settles a bit above a fresh start) | | `MemoryPeak` | highest since it started | | `NRestarts` | > 0 without a deploy = it crashed or hit `MemoryMax` | **A number that keeps climbing across days is a leak. Fix it; don't raise the cap.** Raise the cap only with a measured reason (e.g. a known import job). A cap hides a leak rather than fixing it: the app hits the ceiling, restarts, climbs again, and looks healthy from outside. Only the trend shows it. **Check:** today's and tomorrow's readings are written down and roughly equal. ### 6. Don't write leaks (code review checklist) - Every `setInterval`/`setTimeout` has an owner that clears it. A module imported twice runs its interval twice, forever. - No module-level array/map/cache without a size limit or expiry. "Cache recent results" with no eviction is a leak with a nicer name. - **One database client/pool per process.** `new PrismaClient()` (or `new Pool()`) per request opens a pool per request. - Listeners (`.on(...)`) are removed when their socket/emitter/component goes away. - Close connections on shutdown (`SIGTERM` handler → `app.close()`, `prisma.$disconnect()`). **Check:** in Postgres, connections per app stay small and stable: ```bash sudo -u postgres psql -c "select datname, count(*) from pg_stat_activity group by 1;" ``` ### 7. Decide whether the server is actually too small (server) Answer "do I need a bigger server?" with numbers: ```bash uptime; free -m; df -h /; journalctl -k | grep -ci "out of memory" ``` | Signal | Upgrade when | |---|---| | load average (1 vCPU) | above ~1.00, sustained | | `available` RAM at rest | under ~150 MB | | disk used | above ~70% | | OOM kills | any, after the steps above | If you ever resize: **CPU/RAM only** — reversible. A disk resize is permanent; the server can never shrink below it again. **Check:** the table above filled in with today's values. ## Traps | Symptom | Cause | Fix | |---|---|---| | One app uses 5–6× its peers | runs through `tsx`/`ts-node`/dev server, no heap cap | build to `dist/`, `node --max-old-space-size=128` | | Build works locally, crashes on server: cannot import `.ts` | workspace package ships TS source, `tsc` output imports it | bundle with esbuild, npm packages external | | Site 404s after switching to `dist/`, health is 200 | output folder depth changed relative paths | keep `dist/index.js` at the same depth as `src/index.ts` | | `npm install` on the server gets OOM-killed | building on a 1 GB box | build on the laptop; keep 2 GB swap as a safety net | | `rsync: --chown: unknown option` on a Mac | macOS ships openrsync | staging dir + server-side `sudo rsync --chown` | | Deploy "succeeded", old behaviour remains | build failed, service restarted on stale `dist/` | `npm run build || fail` before any restart | | App restarts every few hours, looks fine | leak hitting `MemoryMax` | read `NRestarts` and the trend; fix the leak | | Heap flag seems ignored | `ExecStart=npm start` passes through a wrapper | `ExecStart=/usr/bin/node --max-old-space-size=128 dist/index.js` | ## Done when - [ ] no production process runs `tsx`, `ts-node`, `nodemon` or a dev server - [ ] every Node unit: `ExecStart=/usr/bin/node --max-old-space-size=128 dist/...`, `MemoryMax=256M`, `Restart=always` - [ ] builds happen on the laptop; the server runs `npm ci --omit=dev` - [ ] the deploy aborts if a build fails - [ ] memory recorded after the deploy and again the next day - [ ] `free -m` shows comfortable `available` memory and swap unused at rest