Every app on the server:
node — never tsx, ts-node, nodemon,
vite dev, next dev, or npm start/npm run dev wrappers that start them;--max-old-space-size=128 and sits under a systemd MemoryMax=256M;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.
| Placeholder | Meaning |
|---|---|
<server> |
SSH alias of the server |
<app> |
systemd unit / app name |
<deploy-user> |
the non-root user the app runs as |
<app-port> |
the app's loopback port |
free -m says the box is filling; systemd's cgroups say who. Every service's
MemoryCurrent counts the process and its children:
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:
systemctl cat <app> | grep -E 'ExecStart|MemoryMax'
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:
// 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("@<your-scope>/") ? 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.jsat the same folder depth assrc/index.tsif the code resolves paths relative toimport.meta.url(e.g. serving../../web/dist). One level deeper and the site 404s while the health check stays green.
Scripts:
"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.
# /etc/systemd/system/<app>.service — the lines that matter
[Service]
WorkingDirectory=/home/<deploy-user>/apps/<app>
EnvironmentFile=/home/<deploy-user>/apps/<app>/.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.
sudo systemctl daemon-reload && sudo systemctl restart <app>
Check:
systemctl show <app> -p MemoryCurrent -p MemoryMax -p NRestarts
ps -o rss=,args= -C node # one node process per app, no tsx, no npm
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 → <web-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 <server> 'ls ~/apps/<app>/node_modules/.bin | grep -cE "^(tsc|tsx|vite)$"' → 0.
systemctl show <app> -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.
setInterval/setTimeout has an owner that clears it. A module imported
twice runs its interval twice, forever.new PrismaClient() (or new Pool()) per
request opens a pool per request..on(...)) are removed when their socket/emitter/component goes away.SIGTERM handler → app.close(), prisma.$disconnect()).Check: in Postgres, connections per app stay small and stable:
sudo -u postgres psql -c "select datname, count(*) from pg_stat_activity group by 1;"
Answer "do I need a bigger server?" with numbers:
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.
| 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 |
tsx, ts-node, nodemon or a dev serverExecStart=/usr/bin/node --max-old-space-size=128 dist/..., MemoryMax=256M, Restart=alwaysnpm ci --omit=devfree -m shows comfortable available memory and swap unused at rest