Backend deployment
Status: Proposed 2026-09-24
The API runs on one EC2 t4g.micro (Ubuntu 24.04, arm64) in us-east-1, as a PM2-managed Node 24 process behind nginx with a certbot certificate for api.vegangrove.org. Small on purpose: clients upload media directly to S3 and Bunny, the database is Atlas, and the companion streams from the Anthropic API, so the host does routing, auth, and queries.
Host layout
- Node 24 from
.nvmrc, installed once for the service user. - PM2 started from
ecosystem.config.cjsin the repo. The file names the process, setsNODE_ENV=production, points atdist/server.js, and enables cluster mode with one instance (at4g.microhas two vCPUs and one gigabyte; a second instance can come later).pm2 startupandpm2 savemake it survive a reboot. - nginx terminates TLS and proxies to the app on loopback. It forwards
X-Forwarded-ForandX-Request-Id; the app setsTRUST_PROXY=1so rate limiting sees the client, not nginx. - Environment file outside the checkout, in a directory only the service user can read, referenced from the ecosystem file. Never inside the repo directory, never committed, never in the docs. Names are in
.env.example; meanings are in configuration. - Releases live side by side under one parent directory, one directory per commit, with a
currentsymlink. PM2 runscurrent.
Atlas access
Atlas M0 allows connections only from listed addresses. The instance has an Elastic IP so the address is stable across stop and start; that address is allowlisted in the Atlas project and written nowhere public. If the instance is replaced, the new Elastic IP is attached first and the allowlist updated before the app starts.
Deploy runbook
Preconditions: the PR is merged, validate was green, and you have the main SHA.
ssh <api-host>as the service user.cd <releases-dir>andgit clone --depth 1 --branch main <repo-url> <sha>(orgit fetchin a cached clone andgit worktree add <sha> <sha>).cd <sha>andnpm ci(dev dependencies are needed for the build).npm run build, thennpm prune --omit=dev.ln -sfn <sha> ../current.pm2 reload ecosystem.config.cjs --update-envfromcurrent. Reload is zero-downtime in cluster mode: the old worker drains after the new one is listening.curl -fsS https://api.vegangrove.org/healthzand expect{"ok":true}.pm2 logs --lines 50 --nostreamand read for a startup error or anEnvError.- Remove release directories older than the last three.
An EnvError at step 8 means a new variable was added to src/config/env.ts and not to the host's env file. Add it, then repeat from step 6. env.ts fails fast on purpose so this is caught at reload, not at the first request that needs the value.
Rollback
Point current back at the previous release directory and reload:
ln -sfn <previous-sha> current
pm2 reload ecosystem.config.cjs --update-env
Under a minute, no rebuild. Schema changes ship additively (new fields optional, new indexes built by Mongoose on boot) so the previous release runs against the current data. A change that cannot roll back this way says so in its PR rollback plan.
Health check
GET /healthz pings the database and returns 200 { ok: true } or 503 { ok: false }. nginx exposes it publicly; it carries no version, host, or uptime detail. The Atlas connection state and PM2's own restart counter are what you read when it fails.
What is not automated
No GitHub Actions job deploys to the host. That needs a credential on GitHub that can reach the box, a decision recorded in open questions rather than defaulted. The runbook is short enough to run by hand for the first releases.