CODEX // oaoisme wiki

§ Host operations — keeping the box alive

Why ralli-roadmap returned 502 — supervising a bare node process

updated 2026-06-15

ralli-roadmap.oaoisme.top (the live rebuild-progress dashboard) started returning 502 Bad Gateway. This is the story of why, and the fix that stops it recurring.

What a 502 actually means here

Nginx terminates TLS and reverse-proxies the host to a local upstream:

ralli-roadmap.oaoisme.top  ──►  proxy_pass http://127.0.0.1:3500;

A 502 is nginx saying "I'm fine, but the thing I proxy to didn't answer." So the fault is never nginx — it's the upstream on :3500. Confirmed in seconds:

ss -ltnp | grep :3500     # → nothing listening
ps aux | grep server.js   # → no roadmap process

The app was simply not running.

Root cause: a bare, unsupervised process

The dashboard is a tiny Express app (/root/work/ralli-roadmap/server.js). It had been started by hand — no systemd unit, no Docker, no pm2. The box runs earlyoom, and during a long, memory-heavy job elsewhere it logged mem avail: 24%. When memory gets tight something gets killed; a hand-started process has nothing to restart it, so it stayed dead and every request became a 502.

The app itself was healthy the whole time — booting it by hand came straight back up (200, and it re-scanned the repo fine). The bug was never in the code; it was the absence of supervision. Contrast its neighbour ralli-backend.service, which has survived the same pressure because systemd keeps restarting it.

The lesson: a service reachable from the internet must be supervised. "I started it in a shell once" is not a deployment — it's a 502 waiting for the next OOM, crash, or reboot.

The fix: a systemd unit (mirroring ralli-backend)

/etc/systemd/system/ralli-roadmap.service:

[Unit]
Description=ralli-roadmap (live rebuild-progress dashboard) — 127.0.0.1:3500
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/root/work/ralli-roadmap
ExecStart=/usr/bin/node server.js
Environment=NODE_ENV=production
Restart=always
RestartSec=3
OOMScoreAdjust=-400          # earlyoom should pick the dev/test hogs, not this 50MB app
StandardOutput=append:/var/log/ralli-roadmap.log
StandardError=append:/var/log/ralli-roadmap.log

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now ralli-roadmap.service

Two properties make this durable:

  • Restart=always + RestartSec=3 — a crash, an OOM kill, or any exit is followed by an automatic respawn three seconds later.
  • OOMScoreAdjust=-400 — earlyoom/the kernel deprioritise this tiny dashboard as an OOM victim, so the real memory hogs (dev/test runs) get reaped first. Belt and braces: even if it is killed, systemd brings it back.
  • WantedBy=multi-user.target + enable — it also survives a reboot.

Proving it actually recovers

Don't trust Restart=always — test it. Kill the managed process and watch systemd respawn:

OLD=$(systemctl show -p MainPID --value ralli-roadmap.service)
kill -9 "$OLD"
sleep 4
systemctl show -p MainPID --value ralli-roadmap.service   # → a NEW pid
curl -s -o /dev/null -w '%{http_code}\n' https://ralli-roadmap.oaoisme.top/   # → 200

New PID, public URL back to 200. The 502 class of outage is closed for this site.

Generalise it

If you ever find a public host 502-ing, the checklist is always the same: it's the upstream, not nginx. grep proxy_pass the vhost to get the port, ss -ltnp | grep :PORT to see if anything's home, and if the answer is "a hand-started process died" — give it a systemd unit with Restart=always rather than starting it by hand again. The second fix is the only one that's actually a fix. See also Why tmux kept dying — OOM resilience for the memory-pressure side of the same coin.