kitdocs · github

Lean production — run several Node apps on a 1 GB server

raw markdown workflows/lean-production.md verified 2026-09-26

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
<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

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:

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'

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:

// 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.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:

"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)

# /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

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 → <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.

5. Measure after every deploy, and a day later (server)

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.

6. Don't write leaks (code review checklist)

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;"

7. Decide whether the server is actually too small (server)

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.

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